@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,7 +1,10 @@
1
1
  import { AppliedEntry, TemplateApplyProblem, TemplateApplyReport, TemplateEntryRejected } from "./template_apply.js";
2
2
  import { BlockPackProvider, ExactResolution, KindResolution, ResolvedEntry, TemplateResolveOutcome, parseBlockPackName, resolveTemplateEntries } from "./template_resolve.js";
3
3
  import { BlockArgsAuthorKeyPrefix, ProjectCreatedTimestamp, ProjectField, ProjectId, ProjectLastModifiedTimestamp, ProjectListEntry, ProjectMetaKey, ProjectResourceType, ProjectStructureAuthorKey, ProjectStructureKey, SchemaVersionCurrent, SchemaVersionKey } from "./project_model.js";
4
- import { AcceptanceFieldPrefix, EnvelopeAcceptance, EnvelopeData, EnvelopeMode, EnvelopeProject, ProjectChangeAction, ProjectFieldUuid, ShareId, ShareProjectsOptions, SharedEnvelopeResourceType, SharingDecision, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, isAcceptanceField, newShareId } from "./sharing_model.js";
4
+ import { TemplateExportProblem } from "./template_export.js";
5
+ import { TemplateShareProblem, unshareableTemplateEntries } from "./template_share.js";
6
+ import { AcceptanceFieldPrefix, EnvelopeAcceptance, EnvelopeData, EnvelopeMode, EnvelopePayload, EnvelopePayloadKind, EnvelopeProject, EnvelopeSchemaVersion, EnvelopeSchemaVersionCurrent, ProjectChangeAction, ProjectFieldUuid, ShareId, ShareProjectsOptions, ShareTemplateOptions, SharedEnvelopeResourceType, SharingDecision, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, envelopeProjectMap, isAcceptanceField, newShareId, normalizeEnvelopeData } from "./sharing_model.js";
7
+ import { ProjectTemplateExportOutcome, locationOf, stringifyProjectTemplateV1 } from "./template_serializer.js";
5
8
  import { BlockPackExplicit, BlockPackSpecAny, BlockPackSpecPrepared, FrontendFromFolder, FrontendFromFolderData, FrontendFromFolderResourceType, FrontendFromLocalTgz, FrontendFromLocalTgzData, FrontendFromLocalTgzResourceType, FrontendFromUrl, FrontendFromUrlData, FrontendFromUrlResourceType, FrontendSpec } from "./block_pack_spec.js";
6
9
  import { TemplateParseOutcome, parseProjectTemplateV1Yaml } from "./template_parser.js";
7
- export { AcceptanceFieldPrefix, type AppliedEntry, BlockArgsAuthorKeyPrefix, BlockPackExplicit, type BlockPackProvider, BlockPackSpecAny, BlockPackSpecPrepared, EnvelopeAcceptance, EnvelopeData, EnvelopeMode, EnvelopeProject, type ExactResolution, FrontendFromFolder, FrontendFromFolderData, FrontendFromFolderResourceType, FrontendFromLocalTgz, FrontendFromLocalTgzData, FrontendFromLocalTgzResourceType, FrontendFromUrl, FrontendFromUrlData, FrontendFromUrlResourceType, FrontendSpec, type KindResolution, ProjectChangeAction, ProjectCreatedTimestamp, type ProjectField, ProjectFieldUuid, ProjectLastModifiedTimestamp, type ProjectListEntry, ProjectMetaKey, ProjectResourceType, ProjectStructureAuthorKey, ProjectStructureKey, type ResolvedEntry, SchemaVersionCurrent, SchemaVersionKey, ShareId, ShareProjectsOptions, SharedEnvelopeResourceType, SharingDecision, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, type TemplateApplyProblem, type TemplateApplyReport, TemplateEntryRejected, type TemplateParseOutcome, type TemplateResolveOutcome, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, isAcceptanceField, newShareId, parseBlockPackName, parseProjectTemplateV1Yaml, resolveTemplateEntries };
10
+ export { AcceptanceFieldPrefix, type AppliedEntry, BlockArgsAuthorKeyPrefix, BlockPackExplicit, type BlockPackProvider, BlockPackSpecAny, BlockPackSpecPrepared, EnvelopeAcceptance, EnvelopeData, EnvelopeMode, EnvelopePayload, EnvelopePayloadKind, EnvelopeProject, EnvelopeSchemaVersion, EnvelopeSchemaVersionCurrent, type ExactResolution, FrontendFromFolder, FrontendFromFolderData, FrontendFromFolderResourceType, FrontendFromLocalTgz, FrontendFromLocalTgzData, FrontendFromLocalTgzResourceType, FrontendFromUrl, FrontendFromUrlData, FrontendFromUrlResourceType, FrontendSpec, type KindResolution, ProjectChangeAction, ProjectCreatedTimestamp, type ProjectField, ProjectFieldUuid, ProjectLastModifiedTimestamp, type ProjectListEntry, ProjectMetaKey, ProjectResourceType, ProjectStructureAuthorKey, ProjectStructureKey, type ProjectTemplateExportOutcome, type ResolvedEntry, SchemaVersionCurrent, SchemaVersionKey, ShareId, ShareProjectsOptions, ShareTemplateOptions, SharedEnvelopeResourceType, SharingDecision, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, type TemplateApplyProblem, type TemplateApplyReport, TemplateEntryRejected, type TemplateExportProblem, type TemplateParseOutcome, type TemplateResolveOutcome, type TemplateShareProblem, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, envelopeProjectMap, isAcceptanceField, locationOf, newShareId, normalizeEnvelopeData, parseBlockPackName, parseProjectTemplateV1Yaml, resolveTemplateEntries, stringifyProjectTemplateV1, unshareableTemplateEntries };
@@ -1,7 +1,9 @@
1
1
  import { BlockArgsAuthorKeyPrefix, ProjectCreatedTimestamp, ProjectLastModifiedTimestamp, ProjectMetaKey, ProjectResourceType, ProjectStructureAuthorKey, ProjectStructureKey, SchemaVersionCurrent, SchemaVersionKey } from "./project_model.js";
2
2
  import { FrontendFromFolderResourceType, FrontendFromLocalTgzResourceType, FrontendFromUrlResourceType } from "./block_pack_spec.js";
3
- import { AcceptanceFieldPrefix, SharedEnvelopeResourceType, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, isAcceptanceField, newShareId } from "./sharing_model.js";
3
+ import { AcceptanceFieldPrefix, EnvelopeSchemaVersionCurrent, SharedEnvelopeResourceType, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, envelopeProjectMap, isAcceptanceField, newShareId, normalizeEnvelopeData } from "./sharing_model.js";
4
4
  import { parseProjectTemplateV1Yaml } from "./template_parser.js";
5
5
  import { parseBlockPackName, resolveTemplateEntries } from "./template_resolve.js";
6
6
  import { TemplateEntryRejected } from "./template_apply.js";
7
- export { AcceptanceFieldPrefix, BlockArgsAuthorKeyPrefix, FrontendFromFolderResourceType, FrontendFromLocalTgzResourceType, FrontendFromUrlResourceType, ProjectCreatedTimestamp, ProjectLastModifiedTimestamp, ProjectMetaKey, ProjectResourceType, ProjectStructureAuthorKey, ProjectStructureKey, SchemaVersionCurrent, SchemaVersionKey, SharedEnvelopeResourceType, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, TemplateEntryRejected, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, isAcceptanceField, newShareId, parseBlockPackName, parseProjectTemplateV1Yaml, resolveTemplateEntries };
7
+ import { locationOf, stringifyProjectTemplateV1 } from "./template_serializer.js";
8
+ import { unshareableTemplateEntries } from "./template_share.js";
9
+ export { AcceptanceFieldPrefix, BlockArgsAuthorKeyPrefix, EnvelopeSchemaVersionCurrent, FrontendFromFolderResourceType, FrontendFromLocalTgzResourceType, FrontendFromUrlResourceType, ProjectCreatedTimestamp, ProjectLastModifiedTimestamp, ProjectMetaKey, ProjectResourceType, ProjectStructureAuthorKey, ProjectStructureKey, SchemaVersionCurrent, SchemaVersionKey, SharedEnvelopeResourceType, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, TemplateEntryRejected, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, envelopeProjectMap, isAcceptanceField, locationOf, newShareId, normalizeEnvelopeData, parseBlockPackName, parseProjectTemplateV1Yaml, resolveTemplateEntries, stringifyProjectTemplateV1, unshareableTemplateEntries };
@@ -55,6 +55,13 @@ function canImpersonate(role) {
55
55
  default: return false;
56
56
  }
57
57
  }
58
+ /** Version written into every new envelope. Bumped from 1 when the payload became discriminated. */
59
+ const EnvelopeSchemaVersionCurrent = 2;
60
+ /** The project map of a projects-payload envelope, or `{}` for any other payload — the one
61
+ * place a project-shaped reader turns a payload into the map it expects. */
62
+ function envelopeProjectMap(data) {
63
+ return data.payload.kind === "projects" ? data.payload.projects : {};
64
+ }
58
65
  /** Dynamic field on SharingState, one per handled share, keyed by shareId. */
59
66
  const decisionField = (shareId) => `decision/${shareId}`;
60
67
  /** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed
@@ -72,13 +79,61 @@ const acceptanceFieldLogin = (name) => name.slice(11);
72
79
  * Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`
73
80
  * blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource
74
81
  * `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node
75
- * path uses `node.getDataAsJson<EnvelopeData>()`, which decodes the same JSON.
82
+ * path decodes the same JSON with `getDataAsJson` and normalizes it with
83
+ * {@link normalizeEnvelopeData} — both paths must, so neither sees the raw v1 shape.
84
+ *
85
+ * `undefined` for an envelope this build cannot act on; see {@link normalizeEnvelopeData}.
76
86
  */
77
87
  function decodeEnvelopeData(data) {
78
- return JSON.parse(Buffer.from(data).toString("utf-8"));
88
+ return normalizeEnvelopeData(JSON.parse(Buffer.from(data).toString("utf-8")));
89
+ }
90
+ /**
91
+ * Brings a decoded envelope blob to the current shape, or reports that this build cannot act
92
+ * on it by returning `undefined` — an unknown `schemaVersion` or an unknown payload kind. A
93
+ * caller hides such a share rather than offering the recipient something it cannot handle.
94
+ *
95
+ * A v1 envelope carried its project map at the top level and had no `payload` field; it reads
96
+ * here as a `projects` payload, so envelopes written before the discriminant existed keep
97
+ * working unchanged.
98
+ */
99
+ function normalizeEnvelopeData(raw) {
100
+ if (typeof raw !== "object" || raw === null) return void 0;
101
+ const e = raw;
102
+ if (!Object.hasOwn(ReadableSchemaVersions, e.schemaVersion)) return void 0;
103
+ const payload = e.payload ?? (e.projects !== void 0 ? {
104
+ kind: "projects",
105
+ projects: e.projects
106
+ } : void 0);
107
+ if (payload === void 0) return void 0;
108
+ if (!Object.hasOwn(KnownPayloadKinds, payload.kind)) return void 0;
109
+ return {
110
+ schemaVersion: 2,
111
+ shareId: e.shareId,
112
+ sharedAt: e.sharedAt,
113
+ expiresAt: e.expiresAt,
114
+ mode: e.mode,
115
+ sender: e.sender,
116
+ title: e.title,
117
+ payload
118
+ };
79
119
  }
120
+ /** Every payload kind this build can act on; anything else is hidden rather than offered.
121
+ * Keyed by {@link EnvelopePayloadKind}, so adding a kind to {@link EnvelopePayload} without
122
+ * teaching the decoder about it is a compile error, not a share that silently disappears. */
123
+ const KnownPayloadKinds = {
124
+ projects: true,
125
+ template: true
126
+ };
127
+ /** Every schema version {@link normalizeEnvelopeData} accepts. Keyed by
128
+ * {@link EnvelopeSchemaVersion}, so widening that union without deciding how the new shape
129
+ * is upcast is a compile error. */
130
+ const ReadableSchemaVersions = {
131
+ 1: true,
132
+ 2: true
133
+ };
80
134
  //#endregion
81
135
  exports.AcceptanceFieldPrefix = AcceptanceFieldPrefix;
136
+ exports.EnvelopeSchemaVersionCurrent = EnvelopeSchemaVersionCurrent;
82
137
  exports.SharedEnvelopeResourceType = SharedEnvelopeResourceType;
83
138
  exports.SharingOutboxField = SharingOutboxField;
84
139
  exports.SharingOutboxResourceType = SharingOutboxResourceType;
@@ -91,7 +146,9 @@ exports.canGrantToEveryone = canGrantToEveryone;
91
146
  exports.canImpersonate = canImpersonate;
92
147
  exports.decisionField = decisionField;
93
148
  exports.decodeEnvelopeData = decodeEnvelopeData;
149
+ exports.envelopeProjectMap = envelopeProjectMap;
94
150
  exports.isAcceptanceField = isAcceptanceField;
95
151
  exports.newShareId = newShareId;
152
+ exports.normalizeEnvelopeData = normalizeEnvelopeData;
96
153
 
97
154
  //# sourceMappingURL=sharing_model.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"sharing_model.cjs","names":["RoleEnum"],"sources":["../../src/model/sharing_model.ts"],"sourcesContent":["import type { ResourceType, Role } from \"@milaboratories/pl-client\";\nimport { Role as RoleEnum } from \"@milaboratories/pl-client\";\nimport type { Branded, ProjectId } from \"@milaboratories/pl-model-common\";\nimport { randomUUID } from \"node:crypto\";\n\n/**\n * Logical identity of a share, stable across replaces. A donor-generated UUID string,\n * branded so it cannot be silently confused with a project id, a login, or a raw field\n * name. Minted once with {@link newShareId}; every other site receives it (from decoded\n * {@link EnvelopeData} or by parsing a `decision/{shareId}` field name) and threads it\n * through unchanged.\n */\nexport type ShareId = Branded<string, \"ShareId\">;\n\n/** Mints a fresh {@link ShareId}. The single place a share's logical identity is created. */\nexport function newShareId(): ShareId {\n return randomUUID() as ShareId;\n}\n\n/** Brands a string already known to be a share id (e.g. parsed from a `decision/{shareId}`\n * field name) as a {@link ShareId}, without minting a new one. */\nexport function asShareId(id: string): ShareId {\n return id as ShareId;\n}\n\n//\n// Pl Model — Project Sharing\n//\n// All sharing structures are defined and managed by the middle layer; the\n// backend knows nothing about envelopes.\n//\n\n/** Field on the donor's clientRoot holding the {@link SharingOutboxResourceType} resource. */\nexport const SharingOutboxField = \"sharingOutbox\";\n/** Field on the acceptor's clientRoot holding the {@link SharingStateResourceType} resource. */\nexport const SharingStateField = \"sharingState\";\n\nexport const SharingOutboxResourceType: ResourceType = { name: \"SharingOutbox\", version: \"1\" };\nexport const SharedEnvelopeResourceType: ResourceType = { name: \"SharedEnvelope\", version: \"1\" };\nexport const SharingStateResourceType: ResourceType = { name: \"SharingState\", version: \"1\" };\n\nexport type EnvelopeMode = \"copy\" | \"read-only\" | \"collaboration\";\n\n/** Per-project decision on change, matching the UI labels: re-snapshot the live source (\"update\"),\n * carry the existing snapshot (\"keep\"), or drop the project from the pack (\"remove\"). */\nexport type ProjectChangeAction = \"keep\" | \"update\" | \"remove\";\n\n/** Key of the per-project envelope maps: a uuid minted per snapshot to name the `project/{uuid}`\n * field. Distinct from {@link ProjectId} — re-snapshotting one source yields a new uuid each time. */\nexport type ProjectFieldUuid = Branded<string, \"ProjectFieldUuid\">;\n\n/**\n * Whether a role may make a resource public (grant to everyone): true for controller,\n * admin; false for workflow and unspecified. The middle layer carries no policy\n * of its own here — a crafted call still hits the backend's role + permission-ceiling gate.\n * `null` (no-auth mode) returns false.\n */\nexport function canGrantToEveryone(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n return true;\n default:\n return false;\n }\n}\n\n/**\n * Whether a role may impersonate another user: open/create another user's root and list\n * the resources that user can access. Mirrors the backend's authorization rule\n * `util/misecurity/role.go` `CanImpersonate` — true for controller and admin only. This is\n * the admin gate for the \"open another user's root\" feature and is intentionally stricter\n * than {@link canGrantToEveryone}, which also returns true for a regular user (a normal user\n * may share their own projects, but must never be offered impersonation). `null` (no-auth\n * mode) returns false.\n */\nexport function canImpersonate(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n return true;\n default:\n return false;\n }\n}\n\n/** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in {@link EnvelopeData.projects}. */\nexport interface EnvelopeProject {\n label: string; // carried so the pending-share UI renders without traversing into the project\n source: ProjectId; // donor's source projectId; supersedes a prior share and matches the snapshot to its live source on change\n updatedAt: number; // ms epoch of the last (re)snapshot\n}\n\n/** Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated. */\nexport interface EnvelopeData {\n schemaVersion: 1;\n shareId: ShareId; // donor-generated UUID; logical share identity, stable across changes\n sharedAt: number; // ms epoch; this instance's creation time — distinguishes instances of one shareId\n expiresAt: number | null; // ms epoch; sharedAt + ttl (default 14 days) for a targeted share; null for share-with-everybody (never expires)\n mode: EnvelopeMode; // what the acceptor's app should do with the contents\n sender: string; // donor login (informational; backend granted_by is authoritative)\n title: string; // display name shown to recipients; defaults to the first project's name\n projects: Record<ProjectFieldUuid, EnvelopeProject>; // contained projects, keyed by project field uuid\n}\n\n/** Dynamic field on SharingState, one per handled share, keyed by shareId. */\nexport const decisionField = (shareId: ShareId) => `decision/${shareId}`;\n\nexport interface SharingDecision {\n decision: \"accepted\" | \"rejected\";\n timestamp: number; // ms epoch — when the acceptor acted\n 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)\n acceptedProjects: string[]; // ids of the projects created in the acceptor's list ([] for a rejected share)\n}\n\n/** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed\n * by recipient login. Written by the acceptor in read-write shares only (Copy & Share,\n * Live collaboration) — the acceptor's writable envelope grant is what permits the\n * write; read-only shares omit it. The donor reads these from its own outbox to see\n * who responded and when. Informational, not authoritative (a writable grant holder\n * could write under another login — same trust assumption as the sender field).\n * Copied forward when a share is changed. */\nexport const AcceptanceFieldPrefix = \"acceptance/\";\nexport const acceptanceField = (login: string) => `${AcceptanceFieldPrefix}${login}`;\nexport const isAcceptanceField = (name: string) => name.startsWith(AcceptanceFieldPrefix);\nexport const acceptanceFieldLogin = (name: string) => name.slice(AcceptanceFieldPrefix.length);\n\nexport interface EnvelopeAcceptance {\n action: \"accepted\" | \"rejected\";\n timestamp: number; // ms since epoch\n}\n\n/**\n * Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`\n * blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource\n * `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node\n * path uses `node.getDataAsJson<EnvelopeData>()`, which decodes the same JSON.\n */\nexport function decodeEnvelopeData(data: Uint8Array): EnvelopeData {\n return JSON.parse(Buffer.from(data).toString(\"utf-8\")) as EnvelopeData;\n}\n\n/**\n * Options for {@link MiddleLayer.shareProjects}.\n *\n * Recipients XOR everyone — two clean variants, not one struct with mutually exclusive\n * optional fields. The everyone variant issues a single make-public grant (the envelope's\n * `expiresAt` is set to `null`, so it never expires); the recipients variant grants each\n * named recipient and the envelope expires after the default TTL.\n */\nexport type ShareProjectsOptions =\n | {\n recipients: string[]; // recipient logins\n title: string; // display name shown to recipients; defaults to the first project's name\n mode: EnvelopeMode; // v1 UI always sends \"copy\"\n }\n | {\n everyone: true; // share with all users on the server\n /**\n * When true and an everyone-share of the same project already exists, refresh it under its\n * stable shareId (recipients who already accepted or rejected are not re-prompted) instead of\n * minting a new share. No-op when no prior everyone-share of the project exists. Callers that\n * don't care pass `false`.\n */\n replace: boolean;\n title: string;\n mode: EnvelopeMode;\n };\n"],"mappings":";;;;AAeA,SAAgB,aAAsB;CACpC,QAAA,GAAA,YAAA,WAAA,CAAkB;AACpB;;;AAIA,SAAgB,UAAU,IAAqB;CAC7C,OAAO;AACT;;AAUA,MAAa,qBAAqB;;AAElC,MAAa,oBAAoB;AAEjC,MAAa,4BAA0C;CAAE,MAAM;CAAiB,SAAS;AAAI;AAC7F,MAAa,6BAA2C;CAAE,MAAM;CAAkB,SAAS;AAAI;AAC/F,MAAa,2BAAyC;CAAE,MAAM;CAAgB,SAAS;AAAI;;;;;;;AAkB3F,SAAgB,mBAAmB,MAA4B;CAC7D,QAAQ,MAAR;EACE,KAAKA,0BAAAA,KAAS;EACd,KAAKA,0BAAAA,KAAS,OACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;;;;;;;;;AAWA,SAAgB,eAAe,MAA4B;CACzD,QAAQ,MAAR;EACE,KAAKA,0BAAAA,KAAS;EACd,KAAKA,0BAAAA,KAAS,OACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;AAsBA,MAAa,iBAAiB,YAAqB,YAAY;;;;;;;;AAgB/D,MAAa,wBAAwB;AACrC,MAAa,mBAAmB,UAAkB,GAAG,wBAAwB;AAC7E,MAAa,qBAAqB,SAAiB,KAAK,WAAW,qBAAqB;AACxF,MAAa,wBAAwB,SAAiB,KAAK,MAAM,EAA4B;;;;;;;AAa7F,SAAgB,mBAAmB,MAAgC;CACjE,OAAO,KAAK,MAAM,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,OAAO,CAAC;AACvD"}
1
+ {"version":3,"file":"sharing_model.cjs","names":["RoleEnum"],"sources":["../../src/model/sharing_model.ts"],"sourcesContent":["import type { ResourceType, Role } from \"@milaboratories/pl-client\";\nimport { Role as RoleEnum } from \"@milaboratories/pl-client\";\nimport type { Branded, ProjectId, ProjectTemplateV1 } from \"@milaboratories/pl-model-common\";\nimport { randomUUID } from \"node:crypto\";\n\n/**\n * Logical identity of a share, stable across replaces. A donor-generated UUID string,\n * branded so it cannot be silently confused with a project id, a login, or a raw field\n * name. Minted once with {@link newShareId}; every other site receives it (from decoded\n * {@link EnvelopeData} or by parsing a `decision/{shareId}` field name) and threads it\n * through unchanged.\n */\nexport type ShareId = Branded<string, \"ShareId\">;\n\n/** Mints a fresh {@link ShareId}. The single place a share's logical identity is created. */\nexport function newShareId(): ShareId {\n return randomUUID() as ShareId;\n}\n\n/** Brands a string already known to be a share id (e.g. parsed from a `decision/{shareId}`\n * field name) as a {@link ShareId}, without minting a new one. */\nexport function asShareId(id: string): ShareId {\n return id as ShareId;\n}\n\n//\n// Pl Model — Project Sharing\n//\n// All sharing structures are defined and managed by the middle layer; the\n// backend knows nothing about envelopes.\n//\n\n/** Field on the donor's clientRoot holding the {@link SharingOutboxResourceType} resource. */\nexport const SharingOutboxField = \"sharingOutbox\";\n/** Field on the acceptor's clientRoot holding the {@link SharingStateResourceType} resource. */\nexport const SharingStateField = \"sharingState\";\n\nexport const SharingOutboxResourceType: ResourceType = { name: \"SharingOutbox\", version: \"1\" };\nexport const SharedEnvelopeResourceType: ResourceType = { name: \"SharedEnvelope\", version: \"1\" };\nexport const SharingStateResourceType: ResourceType = { name: \"SharingState\", version: \"1\" };\n\nexport type EnvelopeMode = \"copy\" | \"read-only\" | \"collaboration\";\n\n/** Per-project decision on change, matching the UI labels: re-snapshot the live source (\"update\"),\n * carry the existing snapshot (\"keep\"), or drop the project from the pack (\"remove\"). */\nexport type ProjectChangeAction = \"keep\" | \"update\" | \"remove\";\n\n/** Key of the per-project envelope maps: a uuid minted per snapshot to name the `project/{uuid}`\n * field. Distinct from {@link ProjectId} — re-snapshotting one source yields a new uuid each time. */\nexport type ProjectFieldUuid = Branded<string, \"ProjectFieldUuid\">;\n\n/**\n * Whether a role may make a resource public (grant to everyone): true for controller,\n * admin; false for workflow and unspecified. The middle layer carries no policy\n * of its own here — a crafted call still hits the backend's role + permission-ceiling gate.\n * `null` (no-auth mode) returns false.\n */\nexport function canGrantToEveryone(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n return true;\n default:\n return false;\n }\n}\n\n/**\n * Whether a role may impersonate another user: open/create another user's root and list\n * the resources that user can access. Mirrors the backend's authorization rule\n * `util/misecurity/role.go` `CanImpersonate` — true for controller and admin only. This is\n * the admin gate for the \"open another user's root\" feature and is intentionally stricter\n * than {@link canGrantToEveryone}, which also returns true for a regular user (a normal user\n * may share their own projects, but must never be offered impersonation). `null` (no-auth\n * mode) returns false.\n */\nexport function canImpersonate(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n return true;\n default:\n return false;\n }\n}\n\n/** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in a\n * `projects` {@link EnvelopePayload}. */\nexport interface EnvelopeProject {\n label: string; // carried so the pending-share UI renders without traversing into the project\n source: ProjectId; // donor's source projectId; supersedes a prior share and matches the snapshot to its live source on change\n updatedAt: number; // ms epoch of the last (re)snapshot\n}\n\n/**\n * What a share carries. The discriminant is what a reader checks before anything else: a\n * client that does not know a kind hides the share instead of offering something it cannot\n * act on.\n *\n * `projects` snapshots ride as `project/{uuid}` fields on the envelope and this map only\n * describes them; a `template` payload has no fields at all — the document is right here.\n */\nexport type EnvelopePayload =\n | { kind: \"projects\"; projects: Record<ProjectFieldUuid, EnvelopeProject> }\n | {\n kind: \"template\";\n document: ProjectTemplateV1;\n /** Label to give the template on the recipient's own shelf. */\n label: string;\n /** Donor login, kept on the accepted template as its provenance. */\n from: string;\n };\n\nexport type EnvelopePayloadKind = EnvelopePayload[\"kind\"];\n\n/** Every envelope schema version this build can read. Adding a version here is what makes\n * {@link normalizeEnvelopeData} accept it; bumping {@link EnvelopeSchemaVersionCurrent} to a\n * version missing from this union is a compile error. */\nexport type EnvelopeSchemaVersion = 1 | 2;\n\n/** Version written into every new envelope. Bumped from 1 when the payload became discriminated. */\nexport const EnvelopeSchemaVersionCurrent = 2 satisfies EnvelopeSchemaVersion;\n\n/**\n * Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated.\n *\n * Always the current version in memory: a v1 envelope (project map at the top level, no\n * `payload` field) is upcast on read by {@link normalizeEnvelopeData}, so no reader past the\n * decode has to know that two shapes ever existed.\n */\nexport interface EnvelopeData {\n schemaVersion: typeof EnvelopeSchemaVersionCurrent;\n shareId: ShareId; // donor-generated UUID; logical share identity, stable across changes\n sharedAt: number; // ms epoch; this instance's creation time — distinguishes instances of one shareId\n expiresAt: number | null; // ms epoch; sharedAt + ttl (default 14 days) for a targeted share; null for share-with-everybody (never expires)\n mode: EnvelopeMode; // what the acceptor's app should do with the contents\n sender: string; // donor login (informational; backend granted_by is authoritative)\n title: string; // display name shown to recipients; defaults to the first project's name\n payload: EnvelopePayload; // what the share carries\n}\n\n/** The project map of a projects-payload envelope, or `{}` for any other payload — the one\n * place a project-shaped reader turns a payload into the map it expects. */\nexport function envelopeProjectMap(data: EnvelopeData): Record<ProjectFieldUuid, EnvelopeProject> {\n return data.payload.kind === \"projects\" ? data.payload.projects : {};\n}\n\n/** Dynamic field on SharingState, one per handled share, keyed by shareId. */\nexport const decisionField = (shareId: ShareId) => `decision/${shareId}`;\n\nexport interface SharingDecision {\n decision: \"accepted\" | \"rejected\";\n timestamp: number; // ms epoch — when the acceptor acted\n 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)\n acceptedProjects: string[]; // ids of the projects created in the acceptor's list ([] for a rejected share, and for a template share, which creates none)\n}\n\n/** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed\n * by recipient login. Written by the acceptor in read-write shares only (Copy & Share,\n * Live collaboration) — the acceptor's writable envelope grant is what permits the\n * write; read-only shares omit it. The donor reads these from its own outbox to see\n * who responded and when. Informational, not authoritative (a writable grant holder\n * could write under another login — same trust assumption as the sender field).\n * Copied forward when a share is changed. */\nexport const AcceptanceFieldPrefix = \"acceptance/\";\nexport const acceptanceField = (login: string) => `${AcceptanceFieldPrefix}${login}`;\nexport const isAcceptanceField = (name: string) => name.startsWith(AcceptanceFieldPrefix);\nexport const acceptanceFieldLogin = (name: string) => name.slice(AcceptanceFieldPrefix.length);\n\nexport interface EnvelopeAcceptance {\n action: \"accepted\" | \"rejected\";\n timestamp: number; // ms since epoch\n}\n\n/**\n * Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`\n * blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource\n * `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node\n * path decodes the same JSON with `getDataAsJson` and normalizes it with\n * {@link normalizeEnvelopeData} — both paths must, so neither sees the raw v1 shape.\n *\n * `undefined` for an envelope this build cannot act on; see {@link normalizeEnvelopeData}.\n */\nexport function decodeEnvelopeData(data: Uint8Array): EnvelopeData | undefined {\n return normalizeEnvelopeData(JSON.parse(Buffer.from(data).toString(\"utf-8\")));\n}\n\n/**\n * Brings a decoded envelope blob to the current shape, or reports that this build cannot act\n * on it by returning `undefined` — an unknown `schemaVersion` or an unknown payload kind. A\n * caller hides such a share rather than offering the recipient something it cannot handle.\n *\n * A v1 envelope carried its project map at the top level and had no `payload` field; it reads\n * here as a `projects` payload, so envelopes written before the discriminant existed keep\n * working unchanged.\n */\nexport function normalizeEnvelopeData(raw: unknown): EnvelopeData | undefined {\n if (typeof raw !== \"object\" || raw === null) return undefined;\n const e = raw as RawEnvelopeData;\n if (!Object.hasOwn(ReadableSchemaVersions, e.schemaVersion)) return undefined;\n\n const payload =\n e.payload ??\n (e.projects !== undefined ? ({ kind: \"projects\", projects: e.projects } as const) : undefined);\n if (payload === undefined) return undefined;\n if (!Object.hasOwn(KnownPayloadKinds, payload.kind)) return undefined;\n\n return {\n schemaVersion: EnvelopeSchemaVersionCurrent,\n shareId: e.shareId,\n sharedAt: e.sharedAt,\n expiresAt: e.expiresAt,\n mode: e.mode,\n sender: e.sender,\n title: e.title,\n payload,\n };\n}\n\n/**\n * Options for {@link MiddleLayer.shareProjects}.\n *\n * Recipients XOR everyone — two clean variants, not one struct with mutually exclusive\n * optional fields. The everyone variant issues a single make-public grant (the envelope's\n * `expiresAt` is set to `null`, so it never expires); the recipients variant grants each\n * named recipient and the envelope expires after the default TTL.\n */\nexport type ShareProjectsOptions =\n | {\n recipients: string[]; // recipient logins\n title: string; // display name shown to recipients; defaults to the first project's name\n mode: EnvelopeMode; // v1 UI always sends \"copy\"\n }\n | {\n everyone: true; // share with all users on the server\n /**\n * When true and an everyone-share of the same project already exists, refresh it under its\n * stable shareId (recipients who already accepted or rejected are not re-prompted) instead of\n * minting a new share. No-op when no prior everyone-share of the project exists. Callers that\n * don't care pass `false`.\n */\n replace: boolean;\n title: string;\n mode: EnvelopeMode;\n };\n\n/**\n * Options for {@link MiddleLayer.shareTemplate}.\n *\n * Recipients XOR everyone, exactly as {@link ShareProjectsOptions}, minus the mode: a template\n * share is always granted read-only, because the recipient copies no resource out of the\n * envelope — the document is in the envelope's own data.\n */\nexport type ShareTemplateOptions =\n | {\n recipients: string[]; // recipient logins\n title: string; // display name shown to recipients; defaults to the template's label\n }\n | {\n everyone: true; // share with all users on the server\n title: string;\n };\n\n//\n// Internals\n//\n\n/** Every payload kind this build can act on; anything else is hidden rather than offered.\n * Keyed by {@link EnvelopePayloadKind}, so adding a kind to {@link EnvelopePayload} without\n * teaching the decoder about it is a compile error, not a share that silently disappears. */\nconst KnownPayloadKinds: Record<EnvelopePayloadKind, true> = {\n projects: true,\n template: true,\n};\n\n/** Every schema version {@link normalizeEnvelopeData} accepts. Keyed by\n * {@link EnvelopeSchemaVersion}, so widening that union without deciding how the new shape\n * is upcast is a compile error. */\nconst ReadableSchemaVersions: Record<EnvelopeSchemaVersion, true> = {\n 1: true,\n 2: true,\n};\n\n/**\n * The envelope blob as it comes off the wire, before {@link normalizeEnvelopeData} decides\n * whether this build can act on it: the version is any number, the payload may be missing,\n * and `projects` is the v1 top-level project map.\n */\ntype RawEnvelopeData = Omit<EnvelopeData, \"schemaVersion\" | \"payload\"> & {\n schemaVersion: number;\n payload?: EnvelopePayload;\n projects?: Record<ProjectFieldUuid, EnvelopeProject>;\n};\n"],"mappings":";;;;AAeA,SAAgB,aAAsB;CACpC,QAAA,GAAA,YAAA,WAAA,CAAkB;AACpB;;;AAIA,SAAgB,UAAU,IAAqB;CAC7C,OAAO;AACT;;AAUA,MAAa,qBAAqB;;AAElC,MAAa,oBAAoB;AAEjC,MAAa,4BAA0C;CAAE,MAAM;CAAiB,SAAS;AAAI;AAC7F,MAAa,6BAA2C;CAAE,MAAM;CAAkB,SAAS;AAAI;AAC/F,MAAa,2BAAyC;CAAE,MAAM;CAAgB,SAAS;AAAI;;;;;;;AAkB3F,SAAgB,mBAAmB,MAA4B;CAC7D,QAAQ,MAAR;EACE,KAAKA,0BAAAA,KAAS;EACd,KAAKA,0BAAAA,KAAS,OACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;;;;;;;;;AAWA,SAAgB,eAAe,MAA4B;CACzD,QAAQ,MAAR;EACE,KAAKA,0BAAAA,KAAS;EACd,KAAKA,0BAAAA,KAAS,OACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;AAqCA,MAAa,+BAA+B;;;AAsB5C,SAAgB,mBAAmB,MAA+D;CAChG,OAAO,KAAK,QAAQ,SAAS,aAAa,KAAK,QAAQ,WAAW,CAAC;AACrE;;AAGA,MAAa,iBAAiB,YAAqB,YAAY;;;;;;;;AAgB/D,MAAa,wBAAwB;AACrC,MAAa,mBAAmB,UAAkB,GAAG,wBAAwB;AAC7E,MAAa,qBAAqB,SAAiB,KAAK,WAAW,qBAAqB;AACxF,MAAa,wBAAwB,SAAiB,KAAK,MAAM,EAA4B;;;;;;;;;;AAgB7F,SAAgB,mBAAmB,MAA4C;CAC7E,OAAO,sBAAsB,KAAK,MAAM,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,OAAO,CAAC,CAAC;AAC9E;;;;;;;;;;AAWA,SAAgB,sBAAsB,KAAwC;CAC5E,IAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM,OAAO,KAAA;CACpD,MAAM,IAAI;CACV,IAAI,CAAC,OAAO,OAAO,wBAAwB,EAAE,aAAa,GAAG,OAAO,KAAA;CAEpE,MAAM,UACJ,EAAE,YACD,EAAE,aAAa,KAAA,IAAa;EAAE,MAAM;EAAY,UAAU,EAAE;CAAS,IAAc,KAAA;CACtF,IAAI,YAAY,KAAA,GAAW,OAAO,KAAA;CAClC,IAAI,CAAC,OAAO,OAAO,mBAAmB,QAAQ,IAAI,GAAG,OAAO,KAAA;CAE5D,OAAO;EACL,eAAA;EACA,SAAS,EAAE;EACX,UAAU,EAAE;EACZ,WAAW,EAAE;EACb,MAAM,EAAE;EACR,QAAQ,EAAE;EACV,OAAO,EAAE;EACT;CACF;AACF;;;;AAqDA,MAAM,oBAAuD;CAC3D,UAAU;CACV,UAAU;AACZ;;;;AAKA,MAAM,yBAA8D;CAClE,GAAG;CACH,GAAG;AACL"}
@@ -1,4 +1,4 @@
1
- import { Branded, ProjectId } from "@milaboratories/pl-model-common";
1
+ import { Branded, ProjectId, ProjectTemplateV1 } from "@milaboratories/pl-model-common";
2
2
  import { ResourceType, Role } from "@milaboratories/pl-client";
3
3
  //#region src/model/sharing_model.d.ts
4
4
  /**
@@ -45,23 +45,59 @@ export declare function canGrantToEveryone(role: Role | null): boolean;
45
45
  * mode) returns false.
46
46
  */
47
47
  export declare function canImpersonate(role: Role | null): boolean;
48
- /** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in {@link EnvelopeData.projects}. */
48
+ /** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in a
49
+ * `projects` {@link EnvelopePayload}. */
49
50
  export interface EnvelopeProject {
50
51
  label: string;
51
52
  source: ProjectId;
52
53
  updatedAt: number;
53
54
  }
54
- /** Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated. */
55
+ /**
56
+ * What a share carries. The discriminant is what a reader checks before anything else: a
57
+ * client that does not know a kind hides the share instead of offering something it cannot
58
+ * act on.
59
+ *
60
+ * `projects` snapshots ride as `project/{uuid}` fields on the envelope and this map only
61
+ * describes them; a `template` payload has no fields at all — the document is right here.
62
+ */
63
+ export type EnvelopePayload = {
64
+ kind: "projects";
65
+ projects: Record<ProjectFieldUuid, EnvelopeProject>;
66
+ } | {
67
+ kind: "template";
68
+ document: ProjectTemplateV1;
69
+ /** Label to give the template on the recipient's own shelf. */
70
+ label: string;
71
+ /** Donor login, kept on the accepted template as its provenance. */
72
+ from: string;
73
+ };
74
+ export type EnvelopePayloadKind = EnvelopePayload["kind"];
75
+ /** Every envelope schema version this build can read. Adding a version here is what makes
76
+ * {@link normalizeEnvelopeData} accept it; bumping {@link EnvelopeSchemaVersionCurrent} to a
77
+ * version missing from this union is a compile error. */
78
+ export type EnvelopeSchemaVersion = 1 | 2;
79
+ /** Version written into every new envelope. Bumped from 1 when the payload became discriminated. */
80
+ export declare const EnvelopeSchemaVersionCurrent = 2;
81
+ /**
82
+ * Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated.
83
+ *
84
+ * Always the current version in memory: a v1 envelope (project map at the top level, no
85
+ * `payload` field) is upcast on read by {@link normalizeEnvelopeData}, so no reader past the
86
+ * decode has to know that two shapes ever existed.
87
+ */
55
88
  export interface EnvelopeData {
56
- schemaVersion: 1;
89
+ schemaVersion: typeof EnvelopeSchemaVersionCurrent;
57
90
  shareId: ShareId;
58
91
  sharedAt: number;
59
92
  expiresAt: number | null;
60
93
  mode: EnvelopeMode;
61
94
  sender: string;
62
95
  title: string;
63
- projects: Record<ProjectFieldUuid, EnvelopeProject>;
96
+ payload: EnvelopePayload;
64
97
  }
98
+ /** The project map of a projects-payload envelope, or `{}` for any other payload — the one
99
+ * place a project-shaped reader turns a payload into the map it expects. */
100
+ export declare function envelopeProjectMap(data: EnvelopeData): Record<ProjectFieldUuid, EnvelopeProject>;
65
101
  /** Dynamic field on SharingState, one per handled share, keyed by shareId. */
66
102
  export declare const decisionField: (shareId: ShareId) => string;
67
103
  export interface SharingDecision {
@@ -89,9 +125,22 @@ export interface EnvelopeAcceptance {
89
125
  * Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`
90
126
  * blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource
91
127
  * `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node
92
- * path uses `node.getDataAsJson<EnvelopeData>()`, which decodes the same JSON.
128
+ * path decodes the same JSON with `getDataAsJson` and normalizes it with
129
+ * {@link normalizeEnvelopeData} — both paths must, so neither sees the raw v1 shape.
130
+ *
131
+ * `undefined` for an envelope this build cannot act on; see {@link normalizeEnvelopeData}.
93
132
  */
94
- export declare function decodeEnvelopeData(data: Uint8Array): EnvelopeData;
133
+ export declare function decodeEnvelopeData(data: Uint8Array): EnvelopeData | undefined;
134
+ /**
135
+ * Brings a decoded envelope blob to the current shape, or reports that this build cannot act
136
+ * on it by returning `undefined` — an unknown `schemaVersion` or an unknown payload kind. A
137
+ * caller hides such a share rather than offering the recipient something it cannot handle.
138
+ *
139
+ * A v1 envelope carried its project map at the top level and had no `payload` field; it reads
140
+ * here as a `projects` payload, so envelopes written before the discriminant existed keep
141
+ * working unchanged.
142
+ */
143
+ export declare function normalizeEnvelopeData(raw: unknown): EnvelopeData | undefined;
95
144
  /**
96
145
  * Options for {@link MiddleLayer.shareProjects}.
97
146
  *
@@ -116,5 +165,19 @@ export type ShareProjectsOptions = {
116
165
  title: string;
117
166
  mode: EnvelopeMode;
118
167
  };
168
+ /**
169
+ * Options for {@link MiddleLayer.shareTemplate}.
170
+ *
171
+ * Recipients XOR everyone, exactly as {@link ShareProjectsOptions}, minus the mode: a template
172
+ * share is always granted read-only, because the recipient copies no resource out of the
173
+ * envelope — the document is in the envelope's own data.
174
+ */
175
+ export type ShareTemplateOptions = {
176
+ recipients: string[];
177
+ title: string;
178
+ } | {
179
+ everyone: true;
180
+ title: string;
181
+ };
119
182
  //#endregion
120
183
  //# sourceMappingURL=sharing_model.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sharing_model.d.ts","names":[],"sources":["../../src/model/sharing_model.ts"],"mappings":";;;;;;;;;;YAYY,UAAU;;wBAGN,cAAc;;;wBAMd,UAAU,aAAa;;qBAY1B;;qBAEA;qBAEA,2BAA2B;qBAC3B,4BAA4B;qBAC5B,0BAA0B;YAE3B;;;YAIA;;;YAIA,mBAAmB;;;;;;;wBAQf,mBAAmB,MAAM;;;;;;;;;;wBAmBzB,eAAe,MAAM;;iBAWpB;EACf;EACA,QAAQ;EACR;;;iBAIe;EACf;EACA,SAAS;EACT;EACA;EACA,MAAM;EACN;EACA;EACA,UAAU,OAAO,kBAAkB;;;qBAIxB,gBAAa,SAAa;iBAEtB;EACf;EACA;EACA;EACA;;;;;;;;;qBAUW;qBACA,kBAAe;qBACf,oBAAiB;qBACjB,uBAAoB;iBAEhB;EACf;EACA;;;;;;;;wBASc,mBAAmB,MAAM,aAAa;;;;;;;;;YAY1C;EAEN;EACA;EACA,MAAM;;EAGN;;;;;;;EAOA;EACA;EACA,MAAM"}
1
+ {"version":3,"file":"sharing_model.d.ts","names":[],"sources":["../../src/model/sharing_model.ts"],"mappings":";;;;;;;;;;YAYY,UAAU;;wBAGN,cAAc;;;wBAMd,UAAU,aAAa;;qBAY1B;;qBAEA;qBAEA,2BAA2B;qBAC3B,4BAA4B;qBAC5B,0BAA0B;YAE3B;;;YAIA;;;YAIA,mBAAmB;;;;;;;wBAQf,mBAAmB,MAAM;;;;;;;;;;wBAmBzB,eAAe,MAAM;;;iBAYpB;EACf;EACA,QAAQ;EACR;;;;;;;;;;YAWU;EACN;EAAkB,UAAU,OAAO,kBAAkB;;EAErD;EACA,UAAU;;EAEV;;EAEA;;YAGM,sBAAsB;;;;YAKtB;;qBAGC;;;;;;;;iBASI;EACf,sBAAsB;EACtB,SAAS;EACT;EACA;EACA,MAAM;EACN;EACA;EACA,SAAS;;;;wBAKK,mBAAmB,MAAM,eAAe,OAAO,kBAAkB;;qBAKpE,gBAAa,SAAa;iBAEtB;EACf;EACA;EACA;EACA;;;;;;;;;qBAUW;qBACA,kBAAe;qBACf,oBAAiB;qBACjB,uBAAoB;iBAEhB;EACf;EACA;;;;;;;;;;;wBAYc,mBAAmB,MAAM,aAAa;;;;;;;;;;wBAatC,sBAAsB,eAAe;;;;;;;;;YA+BzC;EAEN;EACA;EACA,MAAM;;EAGN;;;;;;;EAOA;EACA;EACA,MAAM;;;;;;;;;YAUA;EAEN;EACA;;EAGA;EACA"}
@@ -55,6 +55,13 @@ function canImpersonate(role) {
55
55
  default: return false;
56
56
  }
57
57
  }
58
+ /** Version written into every new envelope. Bumped from 1 when the payload became discriminated. */
59
+ const EnvelopeSchemaVersionCurrent = 2;
60
+ /** The project map of a projects-payload envelope, or `{}` for any other payload — the one
61
+ * place a project-shaped reader turns a payload into the map it expects. */
62
+ function envelopeProjectMap(data) {
63
+ return data.payload.kind === "projects" ? data.payload.projects : {};
64
+ }
58
65
  /** Dynamic field on SharingState, one per handled share, keyed by shareId. */
59
66
  const decisionField = (shareId) => `decision/${shareId}`;
60
67
  /** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed
@@ -72,12 +79,59 @@ const acceptanceFieldLogin = (name) => name.slice(11);
72
79
  * Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`
73
80
  * blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource
74
81
  * `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node
75
- * path uses `node.getDataAsJson<EnvelopeData>()`, which decodes the same JSON.
82
+ * path decodes the same JSON with `getDataAsJson` and normalizes it with
83
+ * {@link normalizeEnvelopeData} — both paths must, so neither sees the raw v1 shape.
84
+ *
85
+ * `undefined` for an envelope this build cannot act on; see {@link normalizeEnvelopeData}.
76
86
  */
77
87
  function decodeEnvelopeData(data) {
78
- return JSON.parse(Buffer.from(data).toString("utf-8"));
88
+ return normalizeEnvelopeData(JSON.parse(Buffer.from(data).toString("utf-8")));
89
+ }
90
+ /**
91
+ * Brings a decoded envelope blob to the current shape, or reports that this build cannot act
92
+ * on it by returning `undefined` — an unknown `schemaVersion` or an unknown payload kind. A
93
+ * caller hides such a share rather than offering the recipient something it cannot handle.
94
+ *
95
+ * A v1 envelope carried its project map at the top level and had no `payload` field; it reads
96
+ * here as a `projects` payload, so envelopes written before the discriminant existed keep
97
+ * working unchanged.
98
+ */
99
+ function normalizeEnvelopeData(raw) {
100
+ if (typeof raw !== "object" || raw === null) return void 0;
101
+ const e = raw;
102
+ if (!Object.hasOwn(ReadableSchemaVersions, e.schemaVersion)) return void 0;
103
+ const payload = e.payload ?? (e.projects !== void 0 ? {
104
+ kind: "projects",
105
+ projects: e.projects
106
+ } : void 0);
107
+ if (payload === void 0) return void 0;
108
+ if (!Object.hasOwn(KnownPayloadKinds, payload.kind)) return void 0;
109
+ return {
110
+ schemaVersion: 2,
111
+ shareId: e.shareId,
112
+ sharedAt: e.sharedAt,
113
+ expiresAt: e.expiresAt,
114
+ mode: e.mode,
115
+ sender: e.sender,
116
+ title: e.title,
117
+ payload
118
+ };
79
119
  }
120
+ /** Every payload kind this build can act on; anything else is hidden rather than offered.
121
+ * Keyed by {@link EnvelopePayloadKind}, so adding a kind to {@link EnvelopePayload} without
122
+ * teaching the decoder about it is a compile error, not a share that silently disappears. */
123
+ const KnownPayloadKinds = {
124
+ projects: true,
125
+ template: true
126
+ };
127
+ /** Every schema version {@link normalizeEnvelopeData} accepts. Keyed by
128
+ * {@link EnvelopeSchemaVersion}, so widening that union without deciding how the new shape
129
+ * is upcast is a compile error. */
130
+ const ReadableSchemaVersions = {
131
+ 1: true,
132
+ 2: true
133
+ };
80
134
  //#endregion
81
- export { AcceptanceFieldPrefix, SharedEnvelopeResourceType, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, isAcceptanceField, newShareId };
135
+ export { AcceptanceFieldPrefix, EnvelopeSchemaVersionCurrent, SharedEnvelopeResourceType, SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, acceptanceField, acceptanceFieldLogin, asShareId, canGrantToEveryone, canImpersonate, decisionField, decodeEnvelopeData, envelopeProjectMap, isAcceptanceField, newShareId, normalizeEnvelopeData };
82
136
 
83
137
  //# sourceMappingURL=sharing_model.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sharing_model.js","names":["RoleEnum"],"sources":["../../src/model/sharing_model.ts"],"sourcesContent":["import type { ResourceType, Role } from \"@milaboratories/pl-client\";\nimport { Role as RoleEnum } from \"@milaboratories/pl-client\";\nimport type { Branded, ProjectId } from \"@milaboratories/pl-model-common\";\nimport { randomUUID } from \"node:crypto\";\n\n/**\n * Logical identity of a share, stable across replaces. A donor-generated UUID string,\n * branded so it cannot be silently confused with a project id, a login, or a raw field\n * name. Minted once with {@link newShareId}; every other site receives it (from decoded\n * {@link EnvelopeData} or by parsing a `decision/{shareId}` field name) and threads it\n * through unchanged.\n */\nexport type ShareId = Branded<string, \"ShareId\">;\n\n/** Mints a fresh {@link ShareId}. The single place a share's logical identity is created. */\nexport function newShareId(): ShareId {\n return randomUUID() as ShareId;\n}\n\n/** Brands a string already known to be a share id (e.g. parsed from a `decision/{shareId}`\n * field name) as a {@link ShareId}, without minting a new one. */\nexport function asShareId(id: string): ShareId {\n return id as ShareId;\n}\n\n//\n// Pl Model — Project Sharing\n//\n// All sharing structures are defined and managed by the middle layer; the\n// backend knows nothing about envelopes.\n//\n\n/** Field on the donor's clientRoot holding the {@link SharingOutboxResourceType} resource. */\nexport const SharingOutboxField = \"sharingOutbox\";\n/** Field on the acceptor's clientRoot holding the {@link SharingStateResourceType} resource. */\nexport const SharingStateField = \"sharingState\";\n\nexport const SharingOutboxResourceType: ResourceType = { name: \"SharingOutbox\", version: \"1\" };\nexport const SharedEnvelopeResourceType: ResourceType = { name: \"SharedEnvelope\", version: \"1\" };\nexport const SharingStateResourceType: ResourceType = { name: \"SharingState\", version: \"1\" };\n\nexport type EnvelopeMode = \"copy\" | \"read-only\" | \"collaboration\";\n\n/** Per-project decision on change, matching the UI labels: re-snapshot the live source (\"update\"),\n * carry the existing snapshot (\"keep\"), or drop the project from the pack (\"remove\"). */\nexport type ProjectChangeAction = \"keep\" | \"update\" | \"remove\";\n\n/** Key of the per-project envelope maps: a uuid minted per snapshot to name the `project/{uuid}`\n * field. Distinct from {@link ProjectId} — re-snapshotting one source yields a new uuid each time. */\nexport type ProjectFieldUuid = Branded<string, \"ProjectFieldUuid\">;\n\n/**\n * Whether a role may make a resource public (grant to everyone): true for controller,\n * admin; false for workflow and unspecified. The middle layer carries no policy\n * of its own here — a crafted call still hits the backend's role + permission-ceiling gate.\n * `null` (no-auth mode) returns false.\n */\nexport function canGrantToEveryone(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n return true;\n default:\n return false;\n }\n}\n\n/**\n * Whether a role may impersonate another user: open/create another user's root and list\n * the resources that user can access. Mirrors the backend's authorization rule\n * `util/misecurity/role.go` `CanImpersonate` — true for controller and admin only. This is\n * the admin gate for the \"open another user's root\" feature and is intentionally stricter\n * than {@link canGrantToEveryone}, which also returns true for a regular user (a normal user\n * may share their own projects, but must never be offered impersonation). `null` (no-auth\n * mode) returns false.\n */\nexport function canImpersonate(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n return true;\n default:\n return false;\n }\n}\n\n/** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in {@link EnvelopeData.projects}. */\nexport interface EnvelopeProject {\n label: string; // carried so the pending-share UI renders without traversing into the project\n source: ProjectId; // donor's source projectId; supersedes a prior share and matches the snapshot to its live source on change\n updatedAt: number; // ms epoch of the last (re)snapshot\n}\n\n/** Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated. */\nexport interface EnvelopeData {\n schemaVersion: 1;\n shareId: ShareId; // donor-generated UUID; logical share identity, stable across changes\n sharedAt: number; // ms epoch; this instance's creation time — distinguishes instances of one shareId\n expiresAt: number | null; // ms epoch; sharedAt + ttl (default 14 days) for a targeted share; null for share-with-everybody (never expires)\n mode: EnvelopeMode; // what the acceptor's app should do with the contents\n sender: string; // donor login (informational; backend granted_by is authoritative)\n title: string; // display name shown to recipients; defaults to the first project's name\n projects: Record<ProjectFieldUuid, EnvelopeProject>; // contained projects, keyed by project field uuid\n}\n\n/** Dynamic field on SharingState, one per handled share, keyed by shareId. */\nexport const decisionField = (shareId: ShareId) => `decision/${shareId}`;\n\nexport interface SharingDecision {\n decision: \"accepted\" | \"rejected\";\n timestamp: number; // ms epoch — when the acceptor acted\n 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)\n acceptedProjects: string[]; // ids of the projects created in the acceptor's list ([] for a rejected share)\n}\n\n/** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed\n * by recipient login. Written by the acceptor in read-write shares only (Copy & Share,\n * Live collaboration) — the acceptor's writable envelope grant is what permits the\n * write; read-only shares omit it. The donor reads these from its own outbox to see\n * who responded and when. Informational, not authoritative (a writable grant holder\n * could write under another login — same trust assumption as the sender field).\n * Copied forward when a share is changed. */\nexport const AcceptanceFieldPrefix = \"acceptance/\";\nexport const acceptanceField = (login: string) => `${AcceptanceFieldPrefix}${login}`;\nexport const isAcceptanceField = (name: string) => name.startsWith(AcceptanceFieldPrefix);\nexport const acceptanceFieldLogin = (name: string) => name.slice(AcceptanceFieldPrefix.length);\n\nexport interface EnvelopeAcceptance {\n action: \"accepted\" | \"rejected\";\n timestamp: number; // ms since epoch\n}\n\n/**\n * Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`\n * blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource\n * `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node\n * path uses `node.getDataAsJson<EnvelopeData>()`, which decodes the same JSON.\n */\nexport function decodeEnvelopeData(data: Uint8Array): EnvelopeData {\n return JSON.parse(Buffer.from(data).toString(\"utf-8\")) as EnvelopeData;\n}\n\n/**\n * Options for {@link MiddleLayer.shareProjects}.\n *\n * Recipients XOR everyone — two clean variants, not one struct with mutually exclusive\n * optional fields. The everyone variant issues a single make-public grant (the envelope's\n * `expiresAt` is set to `null`, so it never expires); the recipients variant grants each\n * named recipient and the envelope expires after the default TTL.\n */\nexport type ShareProjectsOptions =\n | {\n recipients: string[]; // recipient logins\n title: string; // display name shown to recipients; defaults to the first project's name\n mode: EnvelopeMode; // v1 UI always sends \"copy\"\n }\n | {\n everyone: true; // share with all users on the server\n /**\n * When true and an everyone-share of the same project already exists, refresh it under its\n * stable shareId (recipients who already accepted or rejected are not re-prompted) instead of\n * minting a new share. No-op when no prior everyone-share of the project exists. Callers that\n * don't care pass `false`.\n */\n replace: boolean;\n title: string;\n mode: EnvelopeMode;\n };\n"],"mappings":";;;;AAeA,SAAgB,aAAsB;CACpC,OAAO,WAAW;AACpB;;;AAIA,SAAgB,UAAU,IAAqB;CAC7C,OAAO;AACT;;AAUA,MAAa,qBAAqB;;AAElC,MAAa,oBAAoB;AAEjC,MAAa,4BAA0C;CAAE,MAAM;CAAiB,SAAS;AAAI;AAC7F,MAAa,6BAA2C;CAAE,MAAM;CAAkB,SAAS;AAAI;AAC/F,MAAa,2BAAyC;CAAE,MAAM;CAAgB,SAAS;AAAI;;;;;;;AAkB3F,SAAgB,mBAAmB,MAA4B;CAC7D,QAAQ,MAAR;EACE,KAAKA,KAAS;EACd,KAAKA,KAAS,OACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;;;;;;;;;AAWA,SAAgB,eAAe,MAA4B;CACzD,QAAQ,MAAR;EACE,KAAKA,KAAS;EACd,KAAKA,KAAS,OACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;AAsBA,MAAa,iBAAiB,YAAqB,YAAY;;;;;;;;AAgB/D,MAAa,wBAAwB;AACrC,MAAa,mBAAmB,UAAkB,GAAG,wBAAwB;AAC7E,MAAa,qBAAqB,SAAiB,KAAK,WAAW,qBAAqB;AACxF,MAAa,wBAAwB,SAAiB,KAAK,MAAM,EAA4B;;;;;;;AAa7F,SAAgB,mBAAmB,MAAgC;CACjE,OAAO,KAAK,MAAM,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,OAAO,CAAC;AACvD"}
1
+ {"version":3,"file":"sharing_model.js","names":["RoleEnum"],"sources":["../../src/model/sharing_model.ts"],"sourcesContent":["import type { ResourceType, Role } from \"@milaboratories/pl-client\";\nimport { Role as RoleEnum } from \"@milaboratories/pl-client\";\nimport type { Branded, ProjectId, ProjectTemplateV1 } from \"@milaboratories/pl-model-common\";\nimport { randomUUID } from \"node:crypto\";\n\n/**\n * Logical identity of a share, stable across replaces. A donor-generated UUID string,\n * branded so it cannot be silently confused with a project id, a login, or a raw field\n * name. Minted once with {@link newShareId}; every other site receives it (from decoded\n * {@link EnvelopeData} or by parsing a `decision/{shareId}` field name) and threads it\n * through unchanged.\n */\nexport type ShareId = Branded<string, \"ShareId\">;\n\n/** Mints a fresh {@link ShareId}. The single place a share's logical identity is created. */\nexport function newShareId(): ShareId {\n return randomUUID() as ShareId;\n}\n\n/** Brands a string already known to be a share id (e.g. parsed from a `decision/{shareId}`\n * field name) as a {@link ShareId}, without minting a new one. */\nexport function asShareId(id: string): ShareId {\n return id as ShareId;\n}\n\n//\n// Pl Model — Project Sharing\n//\n// All sharing structures are defined and managed by the middle layer; the\n// backend knows nothing about envelopes.\n//\n\n/** Field on the donor's clientRoot holding the {@link SharingOutboxResourceType} resource. */\nexport const SharingOutboxField = \"sharingOutbox\";\n/** Field on the acceptor's clientRoot holding the {@link SharingStateResourceType} resource. */\nexport const SharingStateField = \"sharingState\";\n\nexport const SharingOutboxResourceType: ResourceType = { name: \"SharingOutbox\", version: \"1\" };\nexport const SharedEnvelopeResourceType: ResourceType = { name: \"SharedEnvelope\", version: \"1\" };\nexport const SharingStateResourceType: ResourceType = { name: \"SharingState\", version: \"1\" };\n\nexport type EnvelopeMode = \"copy\" | \"read-only\" | \"collaboration\";\n\n/** Per-project decision on change, matching the UI labels: re-snapshot the live source (\"update\"),\n * carry the existing snapshot (\"keep\"), or drop the project from the pack (\"remove\"). */\nexport type ProjectChangeAction = \"keep\" | \"update\" | \"remove\";\n\n/** Key of the per-project envelope maps: a uuid minted per snapshot to name the `project/{uuid}`\n * field. Distinct from {@link ProjectId} — re-snapshotting one source yields a new uuid each time. */\nexport type ProjectFieldUuid = Branded<string, \"ProjectFieldUuid\">;\n\n/**\n * Whether a role may make a resource public (grant to everyone): true for controller,\n * admin; false for workflow and unspecified. The middle layer carries no policy\n * of its own here — a crafted call still hits the backend's role + permission-ceiling gate.\n * `null` (no-auth mode) returns false.\n */\nexport function canGrantToEveryone(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n return true;\n default:\n return false;\n }\n}\n\n/**\n * Whether a role may impersonate another user: open/create another user's root and list\n * the resources that user can access. Mirrors the backend's authorization rule\n * `util/misecurity/role.go` `CanImpersonate` — true for controller and admin only. This is\n * the admin gate for the \"open another user's root\" feature and is intentionally stricter\n * than {@link canGrantToEveryone}, which also returns true for a regular user (a normal user\n * may share their own projects, but must never be offered impersonation). `null` (no-auth\n * mode) returns false.\n */\nexport function canImpersonate(role: Role | null): boolean {\n switch (role) {\n case RoleEnum.CONTROLLER:\n case RoleEnum.ADMIN:\n return true;\n default:\n return false;\n }\n}\n\n/** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in a\n * `projects` {@link EnvelopePayload}. */\nexport interface EnvelopeProject {\n label: string; // carried so the pending-share UI renders without traversing into the project\n source: ProjectId; // donor's source projectId; supersedes a prior share and matches the snapshot to its live source on change\n updatedAt: number; // ms epoch of the last (re)snapshot\n}\n\n/**\n * What a share carries. The discriminant is what a reader checks before anything else: a\n * client that does not know a kind hides the share instead of offering something it cannot\n * act on.\n *\n * `projects` snapshots ride as `project/{uuid}` fields on the envelope and this map only\n * describes them; a `template` payload has no fields at all — the document is right here.\n */\nexport type EnvelopePayload =\n | { kind: \"projects\"; projects: Record<ProjectFieldUuid, EnvelopeProject> }\n | {\n kind: \"template\";\n document: ProjectTemplateV1;\n /** Label to give the template on the recipient's own shelf. */\n label: string;\n /** Donor login, kept on the accepted template as its provenance. */\n from: string;\n };\n\nexport type EnvelopePayloadKind = EnvelopePayload[\"kind\"];\n\n/** Every envelope schema version this build can read. Adding a version here is what makes\n * {@link normalizeEnvelopeData} accept it; bumping {@link EnvelopeSchemaVersionCurrent} to a\n * version missing from this union is a compile error. */\nexport type EnvelopeSchemaVersion = 1 | 2;\n\n/** Version written into every new envelope. Bumped from 1 when the payload became discriminated. */\nexport const EnvelopeSchemaVersionCurrent = 2 satisfies EnvelopeSchemaVersion;\n\n/**\n * Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated.\n *\n * Always the current version in memory: a v1 envelope (project map at the top level, no\n * `payload` field) is upcast on read by {@link normalizeEnvelopeData}, so no reader past the\n * decode has to know that two shapes ever existed.\n */\nexport interface EnvelopeData {\n schemaVersion: typeof EnvelopeSchemaVersionCurrent;\n shareId: ShareId; // donor-generated UUID; logical share identity, stable across changes\n sharedAt: number; // ms epoch; this instance's creation time — distinguishes instances of one shareId\n expiresAt: number | null; // ms epoch; sharedAt + ttl (default 14 days) for a targeted share; null for share-with-everybody (never expires)\n mode: EnvelopeMode; // what the acceptor's app should do with the contents\n sender: string; // donor login (informational; backend granted_by is authoritative)\n title: string; // display name shown to recipients; defaults to the first project's name\n payload: EnvelopePayload; // what the share carries\n}\n\n/** The project map of a projects-payload envelope, or `{}` for any other payload — the one\n * place a project-shaped reader turns a payload into the map it expects. */\nexport function envelopeProjectMap(data: EnvelopeData): Record<ProjectFieldUuid, EnvelopeProject> {\n return data.payload.kind === \"projects\" ? data.payload.projects : {};\n}\n\n/** Dynamic field on SharingState, one per handled share, keyed by shareId. */\nexport const decisionField = (shareId: ShareId) => `decision/${shareId}`;\n\nexport interface SharingDecision {\n decision: \"accepted\" | \"rejected\";\n timestamp: number; // ms epoch — when the acceptor acted\n 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)\n acceptedProjects: string[]; // ids of the projects created in the acceptor's list ([] for a rejected share, and for a template share, which creates none)\n}\n\n/** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed\n * by recipient login. Written by the acceptor in read-write shares only (Copy & Share,\n * Live collaboration) — the acceptor's writable envelope grant is what permits the\n * write; read-only shares omit it. The donor reads these from its own outbox to see\n * who responded and when. Informational, not authoritative (a writable grant holder\n * could write under another login — same trust assumption as the sender field).\n * Copied forward when a share is changed. */\nexport const AcceptanceFieldPrefix = \"acceptance/\";\nexport const acceptanceField = (login: string) => `${AcceptanceFieldPrefix}${login}`;\nexport const isAcceptanceField = (name: string) => name.startsWith(AcceptanceFieldPrefix);\nexport const acceptanceFieldLogin = (name: string) => name.slice(AcceptanceFieldPrefix.length);\n\nexport interface EnvelopeAcceptance {\n action: \"accepted\" | \"rejected\";\n timestamp: number; // ms since epoch\n}\n\n/**\n * Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`\n * blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource\n * `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node\n * path decodes the same JSON with `getDataAsJson` and normalizes it with\n * {@link normalizeEnvelopeData} — both paths must, so neither sees the raw v1 shape.\n *\n * `undefined` for an envelope this build cannot act on; see {@link normalizeEnvelopeData}.\n */\nexport function decodeEnvelopeData(data: Uint8Array): EnvelopeData | undefined {\n return normalizeEnvelopeData(JSON.parse(Buffer.from(data).toString(\"utf-8\")));\n}\n\n/**\n * Brings a decoded envelope blob to the current shape, or reports that this build cannot act\n * on it by returning `undefined` — an unknown `schemaVersion` or an unknown payload kind. A\n * caller hides such a share rather than offering the recipient something it cannot handle.\n *\n * A v1 envelope carried its project map at the top level and had no `payload` field; it reads\n * here as a `projects` payload, so envelopes written before the discriminant existed keep\n * working unchanged.\n */\nexport function normalizeEnvelopeData(raw: unknown): EnvelopeData | undefined {\n if (typeof raw !== \"object\" || raw === null) return undefined;\n const e = raw as RawEnvelopeData;\n if (!Object.hasOwn(ReadableSchemaVersions, e.schemaVersion)) return undefined;\n\n const payload =\n e.payload ??\n (e.projects !== undefined ? ({ kind: \"projects\", projects: e.projects } as const) : undefined);\n if (payload === undefined) return undefined;\n if (!Object.hasOwn(KnownPayloadKinds, payload.kind)) return undefined;\n\n return {\n schemaVersion: EnvelopeSchemaVersionCurrent,\n shareId: e.shareId,\n sharedAt: e.sharedAt,\n expiresAt: e.expiresAt,\n mode: e.mode,\n sender: e.sender,\n title: e.title,\n payload,\n };\n}\n\n/**\n * Options for {@link MiddleLayer.shareProjects}.\n *\n * Recipients XOR everyone — two clean variants, not one struct with mutually exclusive\n * optional fields. The everyone variant issues a single make-public grant (the envelope's\n * `expiresAt` is set to `null`, so it never expires); the recipients variant grants each\n * named recipient and the envelope expires after the default TTL.\n */\nexport type ShareProjectsOptions =\n | {\n recipients: string[]; // recipient logins\n title: string; // display name shown to recipients; defaults to the first project's name\n mode: EnvelopeMode; // v1 UI always sends \"copy\"\n }\n | {\n everyone: true; // share with all users on the server\n /**\n * When true and an everyone-share of the same project already exists, refresh it under its\n * stable shareId (recipients who already accepted or rejected are not re-prompted) instead of\n * minting a new share. No-op when no prior everyone-share of the project exists. Callers that\n * don't care pass `false`.\n */\n replace: boolean;\n title: string;\n mode: EnvelopeMode;\n };\n\n/**\n * Options for {@link MiddleLayer.shareTemplate}.\n *\n * Recipients XOR everyone, exactly as {@link ShareProjectsOptions}, minus the mode: a template\n * share is always granted read-only, because the recipient copies no resource out of the\n * envelope — the document is in the envelope's own data.\n */\nexport type ShareTemplateOptions =\n | {\n recipients: string[]; // recipient logins\n title: string; // display name shown to recipients; defaults to the template's label\n }\n | {\n everyone: true; // share with all users on the server\n title: string;\n };\n\n//\n// Internals\n//\n\n/** Every payload kind this build can act on; anything else is hidden rather than offered.\n * Keyed by {@link EnvelopePayloadKind}, so adding a kind to {@link EnvelopePayload} without\n * teaching the decoder about it is a compile error, not a share that silently disappears. */\nconst KnownPayloadKinds: Record<EnvelopePayloadKind, true> = {\n projects: true,\n template: true,\n};\n\n/** Every schema version {@link normalizeEnvelopeData} accepts. Keyed by\n * {@link EnvelopeSchemaVersion}, so widening that union without deciding how the new shape\n * is upcast is a compile error. */\nconst ReadableSchemaVersions: Record<EnvelopeSchemaVersion, true> = {\n 1: true,\n 2: true,\n};\n\n/**\n * The envelope blob as it comes off the wire, before {@link normalizeEnvelopeData} decides\n * whether this build can act on it: the version is any number, the payload may be missing,\n * and `projects` is the v1 top-level project map.\n */\ntype RawEnvelopeData = Omit<EnvelopeData, \"schemaVersion\" | \"payload\"> & {\n schemaVersion: number;\n payload?: EnvelopePayload;\n projects?: Record<ProjectFieldUuid, EnvelopeProject>;\n};\n"],"mappings":";;;;AAeA,SAAgB,aAAsB;CACpC,OAAO,WAAW;AACpB;;;AAIA,SAAgB,UAAU,IAAqB;CAC7C,OAAO;AACT;;AAUA,MAAa,qBAAqB;;AAElC,MAAa,oBAAoB;AAEjC,MAAa,4BAA0C;CAAE,MAAM;CAAiB,SAAS;AAAI;AAC7F,MAAa,6BAA2C;CAAE,MAAM;CAAkB,SAAS;AAAI;AAC/F,MAAa,2BAAyC;CAAE,MAAM;CAAgB,SAAS;AAAI;;;;;;;AAkB3F,SAAgB,mBAAmB,MAA4B;CAC7D,QAAQ,MAAR;EACE,KAAKA,KAAS;EACd,KAAKA,KAAS,OACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;;;;;;;;;AAWA,SAAgB,eAAe,MAA4B;CACzD,QAAQ,MAAR;EACE,KAAKA,KAAS;EACd,KAAKA,KAAS,OACZ,OAAO;EACT,SACE,OAAO;CACX;AACF;;AAqCA,MAAa,+BAA+B;;;AAsB5C,SAAgB,mBAAmB,MAA+D;CAChG,OAAO,KAAK,QAAQ,SAAS,aAAa,KAAK,QAAQ,WAAW,CAAC;AACrE;;AAGA,MAAa,iBAAiB,YAAqB,YAAY;;;;;;;;AAgB/D,MAAa,wBAAwB;AACrC,MAAa,mBAAmB,UAAkB,GAAG,wBAAwB;AAC7E,MAAa,qBAAqB,SAAiB,KAAK,WAAW,qBAAqB;AACxF,MAAa,wBAAwB,SAAiB,KAAK,MAAM,EAA4B;;;;;;;;;;AAgB7F,SAAgB,mBAAmB,MAA4C;CAC7E,OAAO,sBAAsB,KAAK,MAAM,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,OAAO,CAAC,CAAC;AAC9E;;;;;;;;;;AAWA,SAAgB,sBAAsB,KAAwC;CAC5E,IAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM,OAAO,KAAA;CACpD,MAAM,IAAI;CACV,IAAI,CAAC,OAAO,OAAO,wBAAwB,EAAE,aAAa,GAAG,OAAO,KAAA;CAEpE,MAAM,UACJ,EAAE,YACD,EAAE,aAAa,KAAA,IAAa;EAAE,MAAM;EAAY,UAAU,EAAE;CAAS,IAAc,KAAA;CACtF,IAAI,YAAY,KAAA,GAAW,OAAO,KAAA;CAClC,IAAI,CAAC,OAAO,OAAO,mBAAmB,QAAQ,IAAI,GAAG,OAAO,KAAA;CAE5D,OAAO;EACL,eAAA;EACA,SAAS,EAAE;EACX,UAAU,EAAE;EACZ,WAAW,EAAE;EACb,MAAM,EAAE;EACR,QAAQ,EAAE;EACV,OAAO,EAAE;EACT;CACF;AACF;;;;AAqDA,MAAM,oBAAuD;CAC3D,UAAU;CACV,UAAU;AACZ;;;;AAKA,MAAM,yBAA8D;CAClE,GAAG;CACH,GAAG;AACL"}
@@ -14,5 +14,41 @@ export type ProjectTemplateExportOutcome = {
14
14
  /** Every block that stands in the way, not just the first. */
15
15
  readonly problems: readonly TemplateExportProblem[];
16
16
  };
17
+ /**
18
+ * The `location` to write for a block installed from the filesystem, or `undefined`
19
+ * for one that came from a registry and therefore needs no locator.
20
+ *
21
+ * Both filesystem spec shapes are emitted, and they anchor at different directories
22
+ * — a dev block at its facade package, an npm-consumed one at its block-pack folder.
23
+ * The document does not distinguish them: one URI is written either way, and telling
24
+ * the two layouts apart is done by looking at what is actually there, by the side
25
+ * that has the filesystem anyway. Encoding the layout in the file instead would
26
+ * freeze today's two shapes into the format.
27
+ *
28
+ * A dev spec carries an OS path and is converted here, which also percent-encodes a
29
+ * path containing spaces. An npm-consumed spec already carries a `file:` URL and is
30
+ * passed through: it is the locator the block itself emitted, and reconstructing one
31
+ * from it could only lose information.
32
+ */
33
+ export declare function locationOf(spec: BlockPackSpec): BlockPackLocationReference | undefined;
34
+ /**
35
+ * Render a template document to YAML text.
36
+ *
37
+ * Two non-default emitter settings, both about the file being read by someone
38
+ * else's code:
39
+ *
40
+ * - **No line folding.** A wrapped scalar still parses, but it makes a diff between
41
+ * two exported templates unreadable, which is most of the reason to prefer YAML
42
+ * over JSON here.
43
+ * - **Quote as if the reader were YAML 1.1**, while still parsing as 1.2. YAML 1.2
44
+ * dropped `yes`/`no`/`on`/`off`/`y`/`n` as booleans and dropped sexagesimal
45
+ * integers, so a 1.2 emitter leaves a params value of `"yes"` or `"1:30"` bare —
46
+ * which a 1.1 reader (PyYAML's default, and Go's yaml.v2) turns into `true` and
47
+ * `90`. A template is a contract for a second implementation, so the safe
48
+ * combination is to quote against the stricter ruleset and read with the looser
49
+ * one: a quoted scalar means the same thing under both. This adds no `%YAML`
50
+ * directive — it only changes which scalars get quotes.
51
+ */
52
+ export declare function stringifyProjectTemplateV1(document: ProjectTemplateV1): string;
17
53
  //#endregion
18
54
  //# sourceMappingURL=template_serializer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"template_serializer.d.ts","names":[],"sources":["../../src/model/template_serializer.ts"],"mappings":";;;;;;YAoCY;WAEG;WACA;;WAEA,UAAU;;WAGV;;WAEA,mBAAmB"}
1
+ {"version":3,"file":"template_serializer.d.ts","names":[],"sources":["../../src/model/template_serializer.ts"],"mappings":";;;;;;YAoCY;WAEG;WACA;;WAEA,UAAU;;WAGV;;WAEA,mBAAmB;;;;;;;;;;;;;;;;;;wBAmBlB,WAAW,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;wBA0HjC,2BAA2B,UAAU"}
@@ -0,0 +1,42 @@
1
+ let _milaboratories_pl_model_common = require("@milaboratories/pl-model-common");
2
+ //#region src/model/template_share.ts
3
+ /**
4
+ * Every entry of a template that cannot travel to another machine, or an empty list for a
5
+ * template that can be shared.
6
+ *
7
+ * An entry's `location` names a place rather than a name, and a `file:` place is a folder on
8
+ * the author's own disk: a recipient resolving it finds nothing, or worse finds something
9
+ * else. Such a template stays perfectly usable where it was made, so it is stored and applied
10
+ * as normal — only sharing it is refused.
11
+ *
12
+ * A location whose scheme cannot be read is refused for the same reason: nothing can resolve
13
+ * it anywhere, here included.
14
+ *
15
+ * Every offending entry is reported, not only the first, so a UI can name each block instead
16
+ * of sending its user round the loop once per entry.
17
+ */
18
+ function unshareableTemplateEntries(document) {
19
+ const problems = [];
20
+ for (const entry of document.blocks) {
21
+ if (entry.location === void 0) continue;
22
+ let scheme;
23
+ try {
24
+ scheme = (0, _milaboratories_pl_model_common.parseBlockPackLocation)(entry.location).scheme;
25
+ } catch (e) {
26
+ problems.push({
27
+ entryId: entry.id,
28
+ error: `Block is installed from a location nothing can resolve: ${e instanceof Error ? e.message : String(e)}`
29
+ });
30
+ continue;
31
+ }
32
+ if (scheme === "file") problems.push({
33
+ entryId: entry.id,
34
+ error: `Block is installed from ${entry.location}, a folder on this machine — it resolves to nothing on the recipient's, so this template cannot be shared`
35
+ });
36
+ }
37
+ return problems;
38
+ }
39
+ //#endregion
40
+ exports.unshareableTemplateEntries = unshareableTemplateEntries;
41
+
42
+ //# sourceMappingURL=template_share.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"template_share.cjs","names":[],"sources":["../../src/model/template_share.ts"],"sourcesContent":["import type { ProjectTemplateV1 } from \"@milaboratories/pl-model-common\";\nimport { parseBlockPackLocation } from \"@milaboratories/pl-model-common\";\n\n/** One template entry standing in the way of sharing the template, and why. */\nexport type TemplateShareProblem = {\n /** The template-local id of the entry the problem belongs to; on an exported template it is\n * the block's project-local uuid. */\n readonly entryId: string;\n readonly error: string;\n};\n\n/**\n * Every entry of a template that cannot travel to another machine, or an empty list for a\n * template that can be shared.\n *\n * An entry's `location` names a place rather than a name, and a `file:` place is a folder on\n * the author's own disk: a recipient resolving it finds nothing, or worse finds something\n * else. Such a template stays perfectly usable where it was made, so it is stored and applied\n * as normal — only sharing it is refused.\n *\n * A location whose scheme cannot be read is refused for the same reason: nothing can resolve\n * it anywhere, here included.\n *\n * Every offending entry is reported, not only the first, so a UI can name each block instead\n * of sending its user round the loop once per entry.\n */\nexport function unshareableTemplateEntries(\n document: ProjectTemplateV1,\n): readonly TemplateShareProblem[] {\n const problems: TemplateShareProblem[] = [];\n for (const entry of document.blocks) {\n if (entry.location === undefined) continue;\n let scheme: string;\n try {\n scheme = parseBlockPackLocation(entry.location).scheme;\n } catch (e) {\n problems.push({\n entryId: entry.id,\n error: `Block is installed from a location nothing can resolve: ${e instanceof Error ? e.message : String(e)}`,\n });\n continue;\n }\n if (scheme === \"file\")\n problems.push({\n entryId: entry.id,\n error:\n `Block is installed from ${entry.location}, a folder on this machine — it resolves ` +\n \"to nothing on the recipient's, so this template cannot be shared\",\n });\n }\n return problems;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA0BA,SAAgB,2BACd,UACiC;CACjC,MAAM,WAAmC,CAAC;CAC1C,KAAK,MAAM,SAAS,SAAS,QAAQ;EACnC,IAAI,MAAM,aAAa,KAAA,GAAW;EAClC,IAAI;EACJ,IAAI;GACF,UAAA,GAAA,gCAAA,uBAAA,CAAgC,MAAM,QAAQ,CAAC,CAAC;EAClD,SAAS,GAAG;GACV,SAAS,KAAK;IACZ,SAAS,MAAM;IACf,OAAO,2DAA2D,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC;GAC7G,CAAC;GACD;EACF;EACA,IAAI,WAAW,QACb,SAAS,KAAK;GACZ,SAAS,MAAM;GACf,OACE,2BAA2B,MAAM,SAAS;EAE9C,CAAC;CACL;CACA,OAAO;AACT"}
@@ -0,0 +1,27 @@
1
+ import { ProjectTemplateV1 } from "@milaboratories/pl-model-common";
2
+ //#region src/model/template_share.d.ts
3
+ /** One template entry standing in the way of sharing the template, and why. */
4
+ export type TemplateShareProblem = {
5
+ /** The template-local id of the entry the problem belongs to; on an exported template it is
6
+ * the block's project-local uuid. */
7
+ readonly entryId: string;
8
+ readonly error: string;
9
+ };
10
+ /**
11
+ * Every entry of a template that cannot travel to another machine, or an empty list for a
12
+ * template that can be shared.
13
+ *
14
+ * An entry's `location` names a place rather than a name, and a `file:` place is a folder on
15
+ * the author's own disk: a recipient resolving it finds nothing, or worse finds something
16
+ * else. Such a template stays perfectly usable where it was made, so it is stored and applied
17
+ * as normal — only sharing it is refused.
18
+ *
19
+ * A location whose scheme cannot be read is refused for the same reason: nothing can resolve
20
+ * it anywhere, here included.
21
+ *
22
+ * Every offending entry is reported, not only the first, so a UI can name each block instead
23
+ * of sending its user round the loop once per entry.
24
+ */
25
+ export declare function unshareableTemplateEntries(document: ProjectTemplateV1): readonly TemplateShareProblem[];
26
+ //#endregion
27
+ //# sourceMappingURL=template_share.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"template_share.d.ts","names":[],"sources":["../../src/model/template_share.ts"],"mappings":";;;YAIY;;;WAGD;WACA;;;;;;;;;;;;;;;;;wBAkBK,2BACd,UAAU,6BACA"}
@@ -0,0 +1,42 @@
1
+ import { parseBlockPackLocation } from "@milaboratories/pl-model-common";
2
+ //#region src/model/template_share.ts
3
+ /**
4
+ * Every entry of a template that cannot travel to another machine, or an empty list for a
5
+ * template that can be shared.
6
+ *
7
+ * An entry's `location` names a place rather than a name, and a `file:` place is a folder on
8
+ * the author's own disk: a recipient resolving it finds nothing, or worse finds something
9
+ * else. Such a template stays perfectly usable where it was made, so it is stored and applied
10
+ * as normal — only sharing it is refused.
11
+ *
12
+ * A location whose scheme cannot be read is refused for the same reason: nothing can resolve
13
+ * it anywhere, here included.
14
+ *
15
+ * Every offending entry is reported, not only the first, so a UI can name each block instead
16
+ * of sending its user round the loop once per entry.
17
+ */
18
+ function unshareableTemplateEntries(document) {
19
+ const problems = [];
20
+ for (const entry of document.blocks) {
21
+ if (entry.location === void 0) continue;
22
+ let scheme;
23
+ try {
24
+ scheme = parseBlockPackLocation(entry.location).scheme;
25
+ } catch (e) {
26
+ problems.push({
27
+ entryId: entry.id,
28
+ error: `Block is installed from a location nothing can resolve: ${e instanceof Error ? e.message : String(e)}`
29
+ });
30
+ continue;
31
+ }
32
+ if (scheme === "file") problems.push({
33
+ entryId: entry.id,
34
+ error: `Block is installed from ${entry.location}, a folder on this machine — it resolves to nothing on the recipient's, so this template cannot be shared`
35
+ });
36
+ }
37
+ return problems;
38
+ }
39
+ //#endregion
40
+ export { unshareableTemplateEntries };
41
+
42
+ //# sourceMappingURL=template_share.js.map