@milaboratories/pl-middle-layer 1.64.40 → 1.64.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +16 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/middle_layer/frontend_path.cjs +1 -0
- package/dist/middle_layer/frontend_path.cjs.map +1 -1
- package/dist/middle_layer/frontend_path.js +1 -0
- package/dist/middle_layer/frontend_path.js.map +1 -1
- package/dist/middle_layer/index.d.ts +2 -1
- package/dist/middle_layer/middle_layer.cjs +443 -16
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.d.ts +153 -12
- package/dist/middle_layer/middle_layer.d.ts.map +1 -1
- package/dist/middle_layer/middle_layer.js +445 -18
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/ops.cjs +2 -1
- package/dist/middle_layer/ops.cjs.map +1 -1
- package/dist/middle_layer/ops.d.ts +5 -1
- package/dist/middle_layer/ops.d.ts.map +1 -1
- package/dist/middle_layer/ops.js +2 -1
- package/dist/middle_layer/ops.js.map +1 -1
- package/dist/middle_layer/project.d.ts +4 -4
- package/dist/middle_layer/project.d.ts.map +1 -1
- package/dist/middle_layer/sharing_list.cjs +203 -0
- package/dist/middle_layer/sharing_list.cjs.map +1 -0
- package/dist/middle_layer/sharing_list.d.ts +41 -0
- package/dist/middle_layer/sharing_list.d.ts.map +1 -0
- package/dist/middle_layer/sharing_list.js +198 -0
- package/dist/middle_layer/sharing_list.js.map +1 -0
- package/dist/model/index.cjs +29 -0
- package/dist/model/index.d.ts +2 -1
- package/dist/model/index.js +4 -0
- package/dist/model/project_model.d.ts +3 -3
- package/dist/model/project_model.d.ts.map +1 -1
- package/dist/model/sharing_model.cjs +81 -0
- package/dist/model/sharing_model.cjs.map +1 -0
- package/dist/model/sharing_model.d.ts +112 -0
- package/dist/model/sharing_model.d.ts.map +1 -0
- package/dist/model/sharing_model.js +68 -0
- package/dist/model/sharing_model.js.map +1 -0
- package/dist/mutator/block-pack/frontend.cjs +1 -0
- package/dist/mutator/block-pack/frontend.cjs.map +1 -1
- package/dist/mutator/block-pack/frontend.js +1 -0
- package/dist/mutator/block-pack/frontend.js.map +1 -1
- package/dist/mutator/sharing.cjs +138 -0
- package/dist/mutator/sharing.cjs.map +1 -0
- package/dist/mutator/sharing.js +130 -0
- package/dist/mutator/sharing.js.map +1 -0
- package/package.json +13 -13
- package/src/middle_layer/index.ts +1 -0
- package/src/middle_layer/middle_layer.ts +644 -22
- package/src/middle_layer/ops.ts +7 -0
- package/src/middle_layer/sharing_list.ts +343 -0
- package/src/model/index.ts +2 -0
- package/src/model/sharing_model.ts +150 -0
- package/src/mutator/sharing.ts +216 -0
package/src/middle_layer/ops.ts
CHANGED
|
@@ -248,6 +248,11 @@ export type MiddleLayerOpsSettings = DriverKitOpsSettings & {
|
|
|
248
248
|
|
|
249
249
|
/** Prioritize this channel if update is available in this block */
|
|
250
250
|
readonly preferredUpdateChannel?: string;
|
|
251
|
+
|
|
252
|
+
/** Default lifetime applied to a targeted shared envelope at creation, as
|
|
253
|
+
* `sharedAt + envelopeTtlMs`. Share-with-everybody envelopes never expire
|
|
254
|
+
* (`expiresAt: null`) and ignore this. */
|
|
255
|
+
readonly envelopeTtlMs: number;
|
|
251
256
|
};
|
|
252
257
|
|
|
253
258
|
export type MiddleLayerOps = MiddleLayerOpsSettings & MiddleLayerOpsPaths;
|
|
@@ -260,6 +265,7 @@ export const DefaultMiddleLayerOpsSettings: Pick<
|
|
|
260
265
|
| "projectRefreshInterval"
|
|
261
266
|
| "devBlockUpdateRecheckInterval"
|
|
262
267
|
| "debugOps"
|
|
268
|
+
| "envelopeTtlMs"
|
|
263
269
|
> = {
|
|
264
270
|
...DefaultDriverKitOpsSettings,
|
|
265
271
|
defaultTreeOptions: {
|
|
@@ -272,6 +278,7 @@ export const DefaultMiddleLayerOpsSettings: Pick<
|
|
|
272
278
|
},
|
|
273
279
|
devBlockUpdateRecheckInterval: 1000,
|
|
274
280
|
projectRefreshInterval: 2000,
|
|
281
|
+
envelopeTtlMs: 14 * 24 * 3600 * 1000, // 14 days
|
|
275
282
|
};
|
|
276
283
|
|
|
277
284
|
export function DefaultMiddleLayerOpsPaths(
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import type { PruningFunction } from "@milaboratories/pl-tree";
|
|
2
|
+
import { SynchronizedTreeState } from "@milaboratories/pl-tree";
|
|
3
|
+
import type { Filter, PlClient, SignedResourceId } from "@milaboratories/pl-client";
|
|
4
|
+
import {
|
|
5
|
+
isEveryoneUserLogin,
|
|
6
|
+
resourceIdToString,
|
|
7
|
+
resourceType,
|
|
8
|
+
resourceTypesEqual,
|
|
9
|
+
treeFilter,
|
|
10
|
+
} from "@milaboratories/pl-client";
|
|
11
|
+
import { Computable } from "@milaboratories/computable";
|
|
12
|
+
import type { MiddleLayerEnvironment } from "./middle_layer";
|
|
13
|
+
import type { ProjectId } from "@milaboratories/pl-model-common";
|
|
14
|
+
import type {
|
|
15
|
+
EnvelopeAcceptance,
|
|
16
|
+
EnvelopeData,
|
|
17
|
+
EnvelopeMode,
|
|
18
|
+
ShareId,
|
|
19
|
+
} from "../model/sharing_model";
|
|
20
|
+
import {
|
|
21
|
+
AcceptanceFieldPrefix,
|
|
22
|
+
asShareId,
|
|
23
|
+
SharedEnvelopeResourceType,
|
|
24
|
+
SharingOutboxResourceType,
|
|
25
|
+
SharingStateResourceType,
|
|
26
|
+
} from "../model/sharing_model";
|
|
27
|
+
|
|
28
|
+
/** Donor-facing view of one outgoing share. */
|
|
29
|
+
export interface OutgoingShare {
|
|
30
|
+
shareId: ShareId; // stable logical identity of the share, preserved across changes
|
|
31
|
+
sharedAt: number; // this instance's creation time (ms epoch)
|
|
32
|
+
expiresAt?: number; // EnvelopeData.expiresAt; null maps to undefined = never expires
|
|
33
|
+
mode: EnvelopeMode;
|
|
34
|
+
title: string; // display name shown to recipients; defaults to the first project's name
|
|
35
|
+
/** One entry per project in the pack, so the change UI can offer a per-project decision.
|
|
36
|
+
* `projectId` is the donor's source project id; `updatedAt` is when this project's
|
|
37
|
+
* snapshot was last (re)taken. */
|
|
38
|
+
projects: { projectId: ProjectId; label: string; updatedAt: number }[];
|
|
39
|
+
/** Full recipient logins, from `ListGrants` on the envelope; `["*"]` for everyone-shares. */
|
|
40
|
+
recipients: string[];
|
|
41
|
+
/** Per recipient who has responded: their decision and when, from acceptance/{login}. */
|
|
42
|
+
responses: Record<string, { action: "accepted" | "rejected"; timestamp: number }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Per-project view for the donor's change UI, from {@link EnvelopeData.projects}. */
|
|
46
|
+
function envelopeProjects(data: EnvelopeData): OutgoingShare["projects"] {
|
|
47
|
+
return Object.values(data.projects).map((p) => ({
|
|
48
|
+
projectId: p.source,
|
|
49
|
+
label: p.label,
|
|
50
|
+
updatedAt: p.updatedAt,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Acceptor-facing view of one pending share. */
|
|
55
|
+
export interface PendingShare {
|
|
56
|
+
shareId: ShareId;
|
|
57
|
+
sender: string; // EnvelopeData.sender, display only
|
|
58
|
+
title: string; // display name shown to recipients; defaults to the first project's name
|
|
59
|
+
mode: EnvelopeMode; // v1 renders only "copy" entries
|
|
60
|
+
grantedAt: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SharingOutboxPruningFunction: PruningFunction = (resource) => {
|
|
64
|
+
if (
|
|
65
|
+
!resourceTypesEqual(resource.type, SharingOutboxResourceType) &&
|
|
66
|
+
!resourceTypesEqual(resource.type, SharedEnvelopeResourceType)
|
|
67
|
+
)
|
|
68
|
+
return [];
|
|
69
|
+
return resource.fields;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Server-side traversal scope (modern resourceTree path). Pruning is client-side ONLY and does
|
|
73
|
+
// NOT stop the backend walk — without a fieldFilter the backend descends through the envelope's
|
|
74
|
+
// project/{uuid} snapshots into the whole project graph (StreamManager etc.), whose field-driven
|
|
75
|
+
// finality predicate then throws on the pruned-to-[] fields. Following fields only FROM the outbox
|
|
76
|
+
// and the envelope stops the walk at the project snapshots (UserProject), which we never traverse.
|
|
77
|
+
const SharingOutboxFieldFilter: Filter = treeFilter.or(
|
|
78
|
+
treeFilter.resourceTypeEq(SharingOutboxResourceType.name),
|
|
79
|
+
treeFilter.resourceTypeEq(SharedEnvelopeResourceType.name),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
/** Intermediate of an outgoing share before its full recipient list is fetched: everything the
|
|
83
|
+
* tree carries synchronously, plus the envelope's signed id for the async `ListGrants` enrich. */
|
|
84
|
+
type OutgoingShareDraft = Omit<OutgoingShare, "recipients"> & { envelopeRid: SignedResourceId };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reactive view of the donor's own outbox. Reads each live envelope's immutable
|
|
88
|
+
* {@link EnvelopeData} plus the per-recipient `acceptance/{login}` records from the tree, then
|
|
89
|
+
* enriches each share's full recipient list via `ListGrants` on the envelope (`["*"]` for an
|
|
90
|
+
* everyone-share). `ListGrants` is gated backend-side to the envelope owner (the donor), which is
|
|
91
|
+
* exactly who reads this view.
|
|
92
|
+
*
|
|
93
|
+
* API-level only in M1 (no UI).
|
|
94
|
+
*/
|
|
95
|
+
export function createOutgoingSharesComputable(
|
|
96
|
+
pl: PlClient,
|
|
97
|
+
tree: SynchronizedTreeState,
|
|
98
|
+
): Computable<OutgoingShare[] | undefined> {
|
|
99
|
+
return Computable.make(
|
|
100
|
+
(ctx) => {
|
|
101
|
+
const node = ctx.accessor(tree.entry()).node();
|
|
102
|
+
if (node === undefined) return undefined;
|
|
103
|
+
|
|
104
|
+
const drafts: OutgoingShareDraft[] = [];
|
|
105
|
+
// Outbox fields are keyed by shareId; each value is a SharedEnvelope.
|
|
106
|
+
for (const fieldName of node.listDynamicFields()) {
|
|
107
|
+
const envelope = node.traverse(fieldName);
|
|
108
|
+
if (envelope === undefined) continue;
|
|
109
|
+
if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
|
|
110
|
+
|
|
111
|
+
const data = envelope.getDataAsJson<EnvelopeData>();
|
|
112
|
+
if (data === undefined) continue;
|
|
113
|
+
|
|
114
|
+
const responses: OutgoingShare["responses"] = {};
|
|
115
|
+
for (const f of envelope.listDynamicFields()) {
|
|
116
|
+
if (!f.startsWith(AcceptanceFieldPrefix)) continue;
|
|
117
|
+
const login = f.slice(AcceptanceFieldPrefix.length);
|
|
118
|
+
const acc = envelope.traverse(f);
|
|
119
|
+
const accData = acc?.getDataAsJson<EnvelopeAcceptance>();
|
|
120
|
+
if (accData === undefined) continue;
|
|
121
|
+
responses[login] = { action: accData.action, timestamp: accData.timestamp };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
drafts.push({
|
|
125
|
+
shareId: data.shareId,
|
|
126
|
+
sharedAt: data.sharedAt,
|
|
127
|
+
...(data.expiresAt !== null ? { expiresAt: data.expiresAt } : {}),
|
|
128
|
+
mode: data.mode,
|
|
129
|
+
title: data.title,
|
|
130
|
+
projects: envelopeProjects(data),
|
|
131
|
+
responses,
|
|
132
|
+
envelopeRid: envelope.id,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
drafts.sort((a, b) => b.sharedAt - a.sharedAt);
|
|
136
|
+
return drafts;
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
// Resolve each share's recipients via listGrants. An everyone-grant maps to "*"; the donor's
|
|
140
|
+
// own grant is dropped. One tx per envelope so a just-revoked rid faults only its own read,
|
|
141
|
+
// which allSettled degrades to []. Recipients aren't cached — a live share's recipient set can
|
|
142
|
+
// change. Transactional listGrants when available, else the standalone gRPC-only RPC.
|
|
143
|
+
postprocessValue: async (
|
|
144
|
+
drafts: OutgoingShareDraft[] | undefined,
|
|
145
|
+
): Promise<OutgoingShare[] | undefined> => {
|
|
146
|
+
if (drafts === undefined) return undefined;
|
|
147
|
+
const self = pl.userResources.authUser;
|
|
148
|
+
const toRecipients = (grants: { user: string }[]): string[] =>
|
|
149
|
+
grants.some((g) => isEveryoneUserLogin(g.user))
|
|
150
|
+
? ["*"]
|
|
151
|
+
: grants.map((g) => g.user).filter((u) => u !== self);
|
|
152
|
+
|
|
153
|
+
const txGrants = pl.hasCapability("txListGrants:v1");
|
|
154
|
+
const settled = await Promise.allSettled(
|
|
155
|
+
drafts.map((d) =>
|
|
156
|
+
txGrants
|
|
157
|
+
? pl.withReadTx("ListShareGrant", (tx) => tx.listGrants(d.envelopeRid))
|
|
158
|
+
: pl.userResources.listGrants(d.envelopeRid),
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return drafts.map(({ envelopeRid: _envelopeRid, ...share }, i): OutgoingShare => {
|
|
163
|
+
const r = settled[i];
|
|
164
|
+
return { ...share, recipients: r.status === "fulfilled" ? toRecipients(r.value) : [] };
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Creates the donor's outbox synchronized tree (single explicit root) plus the
|
|
173
|
+
* {@link OutgoingShare} computable over it.
|
|
174
|
+
*/
|
|
175
|
+
export async function createOutgoingShares(
|
|
176
|
+
pl: PlClient,
|
|
177
|
+
outboxRid: SignedResourceId,
|
|
178
|
+
env: MiddleLayerEnvironment,
|
|
179
|
+
): Promise<{ tree: SynchronizedTreeState; computable: Computable<OutgoingShare[] | undefined> }> {
|
|
180
|
+
const tree = await SynchronizedTreeState.init(
|
|
181
|
+
pl,
|
|
182
|
+
outboxRid,
|
|
183
|
+
{
|
|
184
|
+
...env.ops.defaultTreeOptions,
|
|
185
|
+
pruning: SharingOutboxPruningFunction,
|
|
186
|
+
fieldFilter: SharingOutboxFieldFilter,
|
|
187
|
+
},
|
|
188
|
+
env.logger,
|
|
189
|
+
);
|
|
190
|
+
return { computable: createOutgoingSharesComputable(pl, tree), tree };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const DecisionFieldPrefix = "decision/";
|
|
194
|
+
|
|
195
|
+
const SharedEnvelopePruningFunction: PruningFunction = (resource) => {
|
|
196
|
+
if (!resourceTypesEqual(resource.type, SharedEnvelopeResourceType)) return [];
|
|
197
|
+
return resource.fields;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Discovery only needs each envelope's immutable EnvelopeData (basic resource data); it must NOT
|
|
201
|
+
// descend into the project snapshots. Following fields only FROM the envelope stops the walk at
|
|
202
|
+
// the UserProject snapshots (their fields are never followed).
|
|
203
|
+
const PendingSharesFieldFilter: Filter = treeFilter.resourceTypeEq(SharedEnvelopeResourceType.name);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Creates the acceptor's shared-resource discovery tree (a `{kind:'shared'}` seed over
|
|
207
|
+
* {@link SharedEnvelopeResourceType}) plus the {@link PendingShare} computable over it.
|
|
208
|
+
*
|
|
209
|
+
* An envelope surfaces only when its shareId has NO decision/{shareId} on SharingState —
|
|
210
|
+
* the dedup is applied by the caller, which holds the SharingState tree.
|
|
211
|
+
*/
|
|
212
|
+
export async function createPendingSharesTree(
|
|
213
|
+
pl: PlClient,
|
|
214
|
+
env: MiddleLayerEnvironment,
|
|
215
|
+
): Promise<SynchronizedTreeState> {
|
|
216
|
+
return await SynchronizedTreeState.init(
|
|
217
|
+
pl,
|
|
218
|
+
{
|
|
219
|
+
kind: "shared",
|
|
220
|
+
resourceType: resourceType(
|
|
221
|
+
SharedEnvelopeResourceType.name,
|
|
222
|
+
SharedEnvelopeResourceType.version,
|
|
223
|
+
),
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
...env.ops.defaultTreeOptions,
|
|
227
|
+
pruning: SharedEnvelopePruningFunction,
|
|
228
|
+
fieldFilter: PendingSharesFieldFilter,
|
|
229
|
+
},
|
|
230
|
+
env.logger,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A live envelope discovered in the acceptor's shared-resource tree: its signed resource id and
|
|
235
|
+
* decoded {@link EnvelopeData}. The accept/reject flow keys these by `data.shareId`. */
|
|
236
|
+
export type LiveEnvelope = { rid: SignedResourceId; data: EnvelopeData };
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Builds a Computable yielding the acceptor's currently-live envelopes, read from the
|
|
240
|
+
* shared-resource discovery tree the ML already maintains — the single discovery mechanism.
|
|
241
|
+
* Accept/reject `.getValue()` this instead of re-streaming `ListUserResources`, so there is no
|
|
242
|
+
* second discovery path. The envelope's signed `rid` (from the tree node) is what the write tx
|
|
243
|
+
* needs; `copyEnvelopeProjectsIntoList` reads the project snapshots itself inside the tx.
|
|
244
|
+
*
|
|
245
|
+
* Returns `undefined` while the tree is still empty/unresolved (mirrors the other tree-backed
|
|
246
|
+
* computables here). Yields a flat list; the caller dedups by `shareId` — at most one live
|
|
247
|
+
* envelope per shareId is expected (the donor keeps one), and a replace tears the old one down.
|
|
248
|
+
*/
|
|
249
|
+
export function createLiveEnvelopesComputable(
|
|
250
|
+
sharedTree: SynchronizedTreeState,
|
|
251
|
+
): Computable<LiveEnvelope[] | undefined> {
|
|
252
|
+
return Computable.make((ctx) => {
|
|
253
|
+
const roots = ctx.accessor(sharedTree.rootsEntry()).nodes();
|
|
254
|
+
const result: LiveEnvelope[] = [];
|
|
255
|
+
for (const envelope of roots) {
|
|
256
|
+
if (envelope === undefined) continue;
|
|
257
|
+
if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
|
|
258
|
+
const data = envelope.getDataAsJson<EnvelopeData>();
|
|
259
|
+
if (data === undefined) continue;
|
|
260
|
+
result.push({ rid: envelope.id, data });
|
|
261
|
+
}
|
|
262
|
+
return result;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Builds the {@link PendingShare} computable over the shared-resource discovery tree, filtered
|
|
268
|
+
* against the set of already-handled shareIds (those with a decision/{shareId} in the acceptor's
|
|
269
|
+
* SharingState). Both trees feed one Computable so it recomputes when either changes.
|
|
270
|
+
*
|
|
271
|
+
* `currentUserLogin` (when known) suppresses the user's own shares: a share-with-everybody grants
|
|
272
|
+
* the everyone-user, so the donor discovers their own envelope as a pending share. There is no
|
|
273
|
+
* scenario where a user accepts their own share, so they are dropped from the pending view.
|
|
274
|
+
*/
|
|
275
|
+
export function createPendingSharesComputable(
|
|
276
|
+
sharedTree: SynchronizedTreeState,
|
|
277
|
+
sharingStateTree: SynchronizedTreeState,
|
|
278
|
+
currentUserLogin: string | null,
|
|
279
|
+
): Computable<PendingShare[] | undefined> {
|
|
280
|
+
return Computable.make((ctx) => {
|
|
281
|
+
const roots = ctx.accessor(sharedTree.rootsEntry()).nodes();
|
|
282
|
+
|
|
283
|
+
// Set of shareIds already handled (accepted or rejected) — dedup discovery on the logical share.
|
|
284
|
+
const stateNode = ctx.accessor(sharingStateTree.entry()).node();
|
|
285
|
+
const handled = new Set<ShareId>();
|
|
286
|
+
if (stateNode !== undefined)
|
|
287
|
+
for (const f of stateNode.listDynamicFields())
|
|
288
|
+
if (f.startsWith(DecisionFieldPrefix))
|
|
289
|
+
handled.add(asShareId(f.slice(DecisionFieldPrefix.length)));
|
|
290
|
+
|
|
291
|
+
const result: PendingShare[] = [];
|
|
292
|
+
for (const envelope of roots) {
|
|
293
|
+
if (envelope === undefined) continue;
|
|
294
|
+
if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
|
|
295
|
+
const data = envelope.getDataAsJson<EnvelopeData>();
|
|
296
|
+
if (data === undefined) continue;
|
|
297
|
+
if (handled.has(data.shareId)) continue; // already accepted or rejected
|
|
298
|
+
if (currentUserLogin !== null && data.sender === currentUserLogin) continue; // own share
|
|
299
|
+
if (data.expiresAt !== null && data.expiresAt <= Date.now()) continue;
|
|
300
|
+
|
|
301
|
+
result.push({
|
|
302
|
+
shareId: data.shareId,
|
|
303
|
+
sender: data.sender,
|
|
304
|
+
title: data.title,
|
|
305
|
+
mode: data.mode,
|
|
306
|
+
grantedAt: data.sharedAt,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
result.sort((a, b) => b.grantedAt - a.grantedAt);
|
|
310
|
+
return result;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const SharingStatePruningFunction: PruningFunction = (resource) => {
|
|
315
|
+
if (!resourceTypesEqual(resource.type, SharingStateResourceType)) return [];
|
|
316
|
+
return resource.fields;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// decision/{shareId} values are leaf JSON resources; follow fields only from SharingState.
|
|
320
|
+
const SharingStateFieldFilter: Filter = treeFilter.resourceTypeEq(SharingStateResourceType.name);
|
|
321
|
+
|
|
322
|
+
/** Creates the acceptor's SharingState synchronized tree (single explicit root). */
|
|
323
|
+
export async function createSharingStateTree(
|
|
324
|
+
pl: PlClient,
|
|
325
|
+
stateRid: SignedResourceId,
|
|
326
|
+
env: MiddleLayerEnvironment,
|
|
327
|
+
): Promise<SynchronizedTreeState> {
|
|
328
|
+
return await SynchronizedTreeState.init(
|
|
329
|
+
pl,
|
|
330
|
+
stateRid,
|
|
331
|
+
{
|
|
332
|
+
...env.ops.defaultTreeOptions,
|
|
333
|
+
pruning: SharingStatePruningFunction,
|
|
334
|
+
fieldFilter: SharingStateFieldFilter,
|
|
335
|
+
},
|
|
336
|
+
env.logger,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Maps an envelope resource id (signed) to its string form, for failure reporting. */
|
|
341
|
+
export function envelopeIdString(rid: SignedResourceId): string {
|
|
342
|
+
return resourceIdToString(rid);
|
|
343
|
+
}
|
package/src/model/index.ts
CHANGED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ResourceType, Role } from "@milaboratories/pl-client";
|
|
2
|
+
import { Role as RoleEnum } from "@milaboratories/pl-client";
|
|
3
|
+
import type { Branded, ProjectId } from "@milaboratories/pl-model-common";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Logical identity of a share, stable across replaces. A donor-generated UUID string,
|
|
8
|
+
* branded so it cannot be silently confused with a project id, a login, or a raw field
|
|
9
|
+
* name. Minted once with {@link newShareId}; every other site receives it (from decoded
|
|
10
|
+
* {@link EnvelopeData} or by parsing a `decision/{shareId}` field name) and threads it
|
|
11
|
+
* through unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export type ShareId = Branded<string, "ShareId">;
|
|
14
|
+
|
|
15
|
+
/** Mints a fresh {@link ShareId}. The single place a share's logical identity is created. */
|
|
16
|
+
export function newShareId(): ShareId {
|
|
17
|
+
return randomUUID() as ShareId;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Brands a string already known to be a share id (e.g. parsed from a `decision/{shareId}`
|
|
21
|
+
* field name) as a {@link ShareId}, without minting a new one. */
|
|
22
|
+
export function asShareId(id: string): ShareId {
|
|
23
|
+
return id as ShareId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//
|
|
27
|
+
// Pl Model — Project Sharing
|
|
28
|
+
//
|
|
29
|
+
// All sharing structures are defined and managed by the middle layer; the
|
|
30
|
+
// backend knows nothing about envelopes.
|
|
31
|
+
//
|
|
32
|
+
|
|
33
|
+
/** Field on the donor's clientRoot holding the {@link SharingOutboxResourceType} resource. */
|
|
34
|
+
export const SharingOutboxField = "sharingOutbox";
|
|
35
|
+
/** Field on the acceptor's clientRoot holding the {@link SharingStateResourceType} resource. */
|
|
36
|
+
export const SharingStateField = "sharingState";
|
|
37
|
+
|
|
38
|
+
export const SharingOutboxResourceType: ResourceType = { name: "SharingOutbox", version: "1" };
|
|
39
|
+
export const SharedEnvelopeResourceType: ResourceType = { name: "SharedEnvelope", version: "1" };
|
|
40
|
+
export const SharingStateResourceType: ResourceType = { name: "SharingState", version: "1" };
|
|
41
|
+
|
|
42
|
+
export type EnvelopeMode = "copy" | "read-only" | "collaboration";
|
|
43
|
+
|
|
44
|
+
/** Per-project decision on change, matching the UI labels: re-snapshot the live source ("update"),
|
|
45
|
+
* carry the existing snapshot ("keep"), or drop the project from the pack ("remove"). */
|
|
46
|
+
export type ProjectChangeAction = "keep" | "update" | "remove";
|
|
47
|
+
|
|
48
|
+
/** Key of the per-project envelope maps: a uuid minted per snapshot to name the `project/{uuid}`
|
|
49
|
+
* field. Distinct from {@link ProjectId} — re-snapshotting one source yields a new uuid each time. */
|
|
50
|
+
export type ProjectFieldUuid = Branded<string, "ProjectFieldUuid">;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a role may make a resource public (grant to everyone): true for controller,
|
|
54
|
+
* admin, and user; false for workflow and unspecified. The middle layer carries no policy
|
|
55
|
+
* of its own here — a crafted call still hits the backend's role + permission-ceiling gate.
|
|
56
|
+
* `null` (no-auth mode) returns false.
|
|
57
|
+
*/
|
|
58
|
+
export function canGrantToEveryone(role: Role | null): boolean {
|
|
59
|
+
switch (role) {
|
|
60
|
+
case RoleEnum.CONTROLLER:
|
|
61
|
+
case RoleEnum.ADMIN:
|
|
62
|
+
case RoleEnum.USER:
|
|
63
|
+
return true;
|
|
64
|
+
default:
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in {@link EnvelopeData.projects}. */
|
|
70
|
+
export interface EnvelopeProject {
|
|
71
|
+
label: string; // carried so the pending-share UI renders without traversing into the project
|
|
72
|
+
source: ProjectId; // donor's source projectId; supersedes a prior share and matches the snapshot to its live source on change
|
|
73
|
+
updatedAt: number; // ms epoch of the last (re)snapshot
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Immutable `data` on a SharedEnvelope, set at createEphemeral, never mutated. */
|
|
77
|
+
export interface EnvelopeData {
|
|
78
|
+
schemaVersion: 1;
|
|
79
|
+
shareId: ShareId; // donor-generated UUID; logical share identity, stable across changes
|
|
80
|
+
sharedAt: number; // ms epoch; this instance's creation time — distinguishes instances of one shareId
|
|
81
|
+
expiresAt: number | null; // ms epoch; sharedAt + ttl (default 14 days) for a targeted share; null for share-with-everybody (never expires)
|
|
82
|
+
mode: EnvelopeMode; // what the acceptor's app should do with the contents
|
|
83
|
+
sender: string; // donor login (informational; backend granted_by is authoritative)
|
|
84
|
+
title: string; // display name shown to recipients; defaults to the first project's name
|
|
85
|
+
projects: Record<ProjectFieldUuid, EnvelopeProject>; // contained projects, keyed by project field uuid
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Dynamic field on SharingState, one per handled share, keyed by shareId. */
|
|
89
|
+
export const decisionField = (shareId: ShareId) => `decision/${shareId}`;
|
|
90
|
+
|
|
91
|
+
export interface SharingDecision {
|
|
92
|
+
decision: "accepted" | "rejected";
|
|
93
|
+
timestamp: number; // ms epoch — when the acceptor acted
|
|
94
|
+
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)
|
|
95
|
+
acceptedProjects: string[]; // ids of the projects created in the acceptor's list ([] for a rejected share)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed
|
|
99
|
+
* by recipient login. Written by the acceptor in read-write shares only (Copy & Share,
|
|
100
|
+
* Live collaboration) — the acceptor's writable envelope grant is what permits the
|
|
101
|
+
* write; read-only shares omit it. The donor reads these from its own outbox to see
|
|
102
|
+
* who responded and when. Informational, not authoritative (a writable grant holder
|
|
103
|
+
* could write under another login — same trust assumption as the sender field).
|
|
104
|
+
* Copied forward when a share is changed. */
|
|
105
|
+
export const AcceptanceFieldPrefix = "acceptance/";
|
|
106
|
+
export const acceptanceField = (login: string) => `${AcceptanceFieldPrefix}${login}`;
|
|
107
|
+
export const isAcceptanceField = (name: string) => name.startsWith(AcceptanceFieldPrefix);
|
|
108
|
+
export const acceptanceFieldLogin = (name: string) => name.slice(AcceptanceFieldPrefix.length);
|
|
109
|
+
|
|
110
|
+
export interface EnvelopeAcceptance {
|
|
111
|
+
action: "accepted" | "rejected";
|
|
112
|
+
timestamp: number; // ms since epoch
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Single owner of the raw-data → {@link EnvelopeData} decode. The envelope's immutable `data`
|
|
117
|
+
* blob is UTF-8 JSON set once at createEphemeral; every site that reads it from a raw resource
|
|
118
|
+
* `data` byte buffer (the basic-resource read path) goes through here. The reactive tree-node
|
|
119
|
+
* path uses `node.getDataAsJson<EnvelopeData>()`, which decodes the same JSON.
|
|
120
|
+
*/
|
|
121
|
+
export function decodeEnvelopeData(data: Uint8Array): EnvelopeData {
|
|
122
|
+
return JSON.parse(Buffer.from(data).toString("utf-8")) as EnvelopeData;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Options for {@link MiddleLayer.shareProjects}.
|
|
127
|
+
*
|
|
128
|
+
* Recipients XOR everyone — two clean variants, not one struct with mutually exclusive
|
|
129
|
+
* optional fields. The everyone variant issues a single make-public grant (the envelope's
|
|
130
|
+
* `expiresAt` is set to `null`, so it never expires); the recipients variant grants each
|
|
131
|
+
* named recipient and the envelope expires after the default TTL.
|
|
132
|
+
*/
|
|
133
|
+
export type ShareProjectsOptions =
|
|
134
|
+
| {
|
|
135
|
+
recipients: string[]; // recipient logins
|
|
136
|
+
title: string; // display name shown to recipients; defaults to the first project's name
|
|
137
|
+
mode: EnvelopeMode; // v1 UI always sends "copy"
|
|
138
|
+
}
|
|
139
|
+
| {
|
|
140
|
+
everyone: true; // share with all users on the server
|
|
141
|
+
/**
|
|
142
|
+
* When true and an everyone-share of the same project already exists, refresh it under its
|
|
143
|
+
* stable shareId (recipients who already accepted or rejected are not re-prompted) instead of
|
|
144
|
+
* minting a new share. No-op when no prior everyone-share of the project exists. Callers that
|
|
145
|
+
* don't care pass `false`.
|
|
146
|
+
*/
|
|
147
|
+
replace: boolean;
|
|
148
|
+
title: string;
|
|
149
|
+
mode: EnvelopeMode;
|
|
150
|
+
};
|