@larc-iu/plaid-client 0.2.0-alpha.2 → 0.2.0-alpha.20

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Metadata ops: the body of every metadata PATCH, and the `metadata` of a
3
+ * bulk update entry. An op is `{op: 'set', path, value}` or
4
+ * `{op: 'delete', path}`, where `path` is a non-empty array of keys into the
5
+ * nested metadata, the first a top-level key. See the manual, "Metadata".
6
+ */
7
+
8
+ import { PLAID_NAMESPACE } from './roles.js';
9
+ import { PROVENANCE_KEYS } from './provenance.js';
10
+
11
+ const RESERVED_KEYS = new Set([PLAID_NAMESPACE, ...PROVENANCE_KEYS]);
12
+
13
+ /**
14
+ * True for a top-level metadata key Plaid keeps for itself: the shared `plaid`
15
+ * namespace (settings every app reads, such as the text direction) and the
16
+ * provenance keys. Neither is ever a user's metadata field, so an app neither
17
+ * lists it as one nor writes a field's value to it.
18
+ * @param {string} key
19
+ * @returns {boolean}
20
+ */
21
+ export const isReservedMetadataKey = (key) => RESERVED_KEYS.has(key);
22
+
23
+ // The longest top-level key the server takes, in UTF-16 code units (Java's
24
+ // String length, which is what it counts).
25
+ const MAX_KEY_LENGTH = 200;
26
+
27
+ // Java's Character.isWhitespace, which the server's blank check uses: the
28
+ // Unicode space separators except the three no-break spaces, and the ASCII
29
+ // whitespace controls.
30
+ const isJavaWhitespace = (c) =>
31
+ (/[\p{Zs}\p{Zl}\p{Zp}]/u.test(c) && c !== '\u00A0' && c !== '\u2007' && c !== '\u202F') ||
32
+ /[\t\n\u000B\f\r\u001C-\u001F]/.test(c);
33
+
34
+ /**
35
+ * True when the server accepts `k` as a top-level metadata key: not blank,
36
+ * at most 200 UTF-16 code units, and no ASCII control character.
37
+ * @param {string} k
38
+ * @returns {boolean}
39
+ */
40
+ const validMetadataKey = (k) =>
41
+ typeof k === 'string' &&
42
+ k.length <= MAX_KEY_LENGTH &&
43
+ !/[\u0000-\u001F\u007F]/.test(k) &&
44
+ ![...k].every(isJavaWhitespace);
45
+
46
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
47
+
48
+ /**
49
+ * The ops that set each top-level key of `fragment`, deleting a key whose
50
+ * value is null. This is how a provenance fragment (verifyOnEdit,
51
+ * contributeOnEdit, ...) is sent as a patch.
52
+ * @param {Object|null|undefined} fragment
53
+ * @returns {Array<{op: string, path: string[], value?: any}>}
54
+ */
55
+ export const metadataOps = (fragment) =>
56
+ Object.entries(fragment || {}).map(([k, v]) =>
57
+ v === null ? { op: 'delete', path: [k] } : { op: 'set', path: [k], value: v },
58
+ );
59
+
60
+ const applyOp = (node, path, depth, o) => {
61
+ const [k, ...more] = path.slice(depth);
62
+ if (more.length === 0) {
63
+ const out = { ...node };
64
+ if (o.op === 'set') out[k] = o.value;
65
+ else delete out[k];
66
+ return out;
67
+ }
68
+ const child = Object.prototype.hasOwnProperty.call(node, k) ? node[k] : undefined;
69
+ if (child === undefined) {
70
+ return o.op === 'set' ? { ...node, [k]: applyOp({}, path, depth + 1, o) } : node;
71
+ }
72
+ if (!isObject(child)) {
73
+ throw new Error(
74
+ `Metadata path ${JSON.stringify(path.slice(0, depth + 1))} holds a value that is not an object`,
75
+ );
76
+ }
77
+ return { ...node, [k]: applyOp(child, path, depth + 1, o) };
78
+ };
79
+
80
+ /**
81
+ * Apply ops to a local copy of an entity's metadata the way the server does,
82
+ * for an optimistic update. Returns a new object and never mutates its input.
83
+ * Throws where the server would refuse (a path through a non-object, or a
84
+ * first key that is blank, over 200 characters or holds a control character).
85
+ * @param {Object|null|undefined} metadata
86
+ * @param {Array<{op: string, path: string[], value?: any}>} ops
87
+ * @returns {Object}
88
+ */
89
+ export const applyMetadataOps = (metadata, ops) =>
90
+ (ops || []).reduce((m, o) => {
91
+ if (!Array.isArray(o.path) || o.path.length === 0) {
92
+ throw new Error('A metadata op needs a non-empty path');
93
+ }
94
+ if (!validMetadataKey(o.path[0])) {
95
+ throw new Error('Invalid metadata key');
96
+ }
97
+ if (o.op !== 'set' && o.op !== 'delete') {
98
+ throw new Error(`Unknown metadata op '${o.op}': expected set or delete`);
99
+ }
100
+ if (o.op === 'set' && !('value' in o)) {
101
+ throw new Error('A set op needs a value');
102
+ }
103
+ return applyOp(m, o.path, 0, o);
104
+ }, { ...(metadata || {}) });
105
+
106
+ /**
107
+ * Merge a top-level fragment into a local copy, a null value deleting the
108
+ * key: applyMetadataOps over metadataOps(fragment), so the same result as
109
+ * sending those ops, refused where the server would refuse them. How an app
110
+ * mirrors a provenance stamp (verifyOnEdit, contributeOnEdit, ...) on the row
111
+ * it shows. Returns a new object.
112
+ * @param {Object|null|undefined} metadata
113
+ * @param {Object|null|undefined} fragment
114
+ * @returns {Object}
115
+ */
116
+ export const mergeMetadata = (metadata, fragment) =>
117
+ applyMetadataOps(metadata, metadataOps(fragment));
package/src/pagination.js CHANGED
@@ -10,6 +10,10 @@
10
10
  * Each helper threads the client through to `client._request`, so auth
11
11
  * headers, base URL, and response transforms all apply exactly as for any
12
12
  * other request.
13
+ *
14
+ * A page is a read, so it goes over the wire even when the helper is handed a
15
+ * batch (see the note at the top of http.js): each page is answered from the
16
+ * pool, in order, against the state no batch has committed yet.
13
17
  */
14
18
 
15
19
  // Merge caller query params with paging params, dropping undefined/null so we
@@ -21,16 +25,23 @@ function buildQueryParams(query, limit, cursor) {
21
25
  return params;
22
26
  }
23
27
 
28
+ // Every collection endpoint answers with the envelope. A bare array or any
29
+ // other shape is a broken endpoint, so name it and stop rather than return a
30
+ // silently truncated result.
31
+ function entriesOf(response, path) {
32
+ if (!response || !Array.isArray(response.entries)) {
33
+ throw new Error(
34
+ `GET ${path} did not return a paginated envelope (no 'entries' array)`,
35
+ );
36
+ }
37
+ return response.entries;
38
+ }
39
+
24
40
  /**
25
41
  * Fetch every page and return the full flat array of entries, transparently
26
42
  * following `nextCursor` until it is null. This is what `.list()` calls so it
27
43
  * stays backward compatible with the pre-pagination (bare-array) contract.
28
44
  *
29
- * NOTE: This auto-paginates and therefore CANNOT be used inside a batch — each
30
- * page's request needs the previous page's `nextCursor`, which doesn't exist
31
- * until the batch executes. It throws immediately when `client.isBatching` is
32
- * true. Use `listPage` for a single page inside a batch.
33
- *
34
45
  * @param {object} client - PlaidClient instance
35
46
  * @param {string} path - API path, e.g. '/api/v1/projects'
36
47
  * @param {object} [opts]
@@ -39,11 +50,6 @@ function buildQueryParams(query, limit, cursor) {
39
50
  * @returns {Promise<Array>} The concatenated entries across all pages
40
51
  */
41
52
  export async function listAll(client, path, { pageSize = 1000, query = {} } = {}) {
42
- if (client.isBatching) {
43
- throw new Error(
44
- `Cannot auto-paginate ${path} inside a batch: list methods follow cursors across multiple requests, which a batch cannot do. Use listPage() for a single page inside a batch, or call list() outside the batch.`,
45
- );
46
- }
47
53
  const all = [];
48
54
  let cursor = null;
49
55
  let prevCursor;
@@ -51,19 +57,7 @@ export async function listAll(client, path, { pageSize = 1000, query = {} } = {}
51
57
  const response = await client._request('GET', path, {
52
58
  queryParams: buildQueryParams(query, pageSize, cursor),
53
59
  });
54
- // Compatibility shim: a non-paginated server (or proxy) may return a bare
55
- // array. Treat it as a terminal full result with no further paging.
56
- if (Array.isArray(response)) {
57
- all.push(...response);
58
- break;
59
- }
60
- if (response && Array.isArray(response.entries)) {
61
- all.push(...response.entries);
62
- } else {
63
- throw new Error(
64
- "Unexpected list response shape (no 'entries'); server may be incompatible.",
65
- );
66
- }
60
+ all.push(...entriesOf(response, path));
67
61
  prevCursor = cursor;
68
62
  cursor = response.nextCursor;
69
63
  // Guard against a buggy server/proxy that returns a constant non-null
@@ -98,11 +92,6 @@ export async function listPage(client, path, { limit, cursor, query = {} } = {})
98
92
  * Async generator yielding each page's entries array in turn, following
99
93
  * `nextCursor` until it is null.
100
94
  *
101
- * NOTE: This auto-paginates and therefore CANNOT be used inside a batch — each
102
- * page's request needs the previous page's `nextCursor`, which doesn't exist
103
- * until the batch executes. It throws on first iteration when
104
- * `client.isBatching` is true. Use `listPage` for a single page inside a batch.
105
- *
106
95
  * @param {object} client - PlaidClient instance
107
96
  * @param {string} path - API path
108
97
  * @param {object} [opts]
@@ -111,18 +100,13 @@ export async function listPage(client, path, { limit, cursor, query = {} } = {})
111
100
  * @yields {Array} The entries array for each page
112
101
  */
113
102
  export async function* iterPages(client, path, { pageSize = 1000, query = {} } = {}) {
114
- if (client.isBatching) {
115
- throw new Error(
116
- `Cannot auto-paginate ${path} inside a batch: list methods follow cursors across multiple requests, which a batch cannot do. Use listPage() for a single page inside a batch, or call list() outside the batch.`,
117
- );
118
- }
119
103
  let cursor = null;
120
104
  let prevCursor;
121
105
  do {
122
106
  const response = await client._request('GET', path, {
123
107
  queryParams: buildQueryParams(query, pageSize, cursor),
124
108
  });
125
- const entries = (response && Array.isArray(response.entries)) ? response.entries : [];
109
+ const entries = entriesOf(response, path);
126
110
  // Suppress the trailing empty page that the server emits when a collection's
127
111
  // size is an exact multiple of the page size (a final full page with a
128
112
  // non-null cursor, then an empty page). Still follow the cursor below.
@@ -130,7 +114,7 @@ export async function* iterPages(client, path, { pageSize = 1000, query = {} } =
130
114
  yield entries;
131
115
  }
132
116
  prevCursor = cursor;
133
- cursor = response ? response.nextCursor : null;
117
+ cursor = response.nextCursor;
134
118
  // Guard against a buggy server/proxy that returns a constant non-null
135
119
  // cursor, which would otherwise loop forever.
136
120
  if (cursor !== null && cursor !== undefined && cursor === prevCursor) {
package/src/provenance.js CHANGED
@@ -5,28 +5,53 @@
5
5
  * Flat scalar keys — the query engine matches flat metadata well, nested
6
6
  * objects poorly.
7
7
  *
8
- * Three states (the PRESENCE of the `prov` key, not its value, is the
9
- * discriminator; absent keys mean "human"):
8
+ * Two axes. ORIGIN is the `prov` key: absent means a trusted person (a
9
+ * verifier) made it; 'inferred' means an algorithm or service did;
10
+ * 'contributed' means a person whose work the project reviews (a
11
+ * contributor) did. TRUST is `provConfirmed`: true once a verifier vouched
12
+ * for the value. Together they give four states:
10
13
  *
11
- * human — no prov keys; a person made it.
14
+ * human — no prov keys; a verifier made it.
12
15
  * machine — { prov: 'inferred', provSource: '<producer>' };
13
- * an algorithm/service made it, no human has vouched.
14
- * verified — same + { provConfirmed: true }; machine-made and a
15
- * human confirmed (or edited) it. provSource stays so
16
- * machine origin remains traceable.
16
+ * an algorithm/service made it, nobody has vouched.
17
+ * contributed — { prov: 'contributed', provSource: 'user:<id>' };
18
+ * a contributor made it, no verifier has vouched.
19
+ * verified — either origin + { provConfirmed: true }; a verifier
20
+ * confirmed (or edited) it. prov/provSource stay so
21
+ * the origin remains traceable.
17
22
  *
18
23
  * The write contract every machine writer must follow:
19
24
  * 1. Machine writers may freely replace MACHINE (unverified) material.
20
- * 2. Machine writers must never modify or delete human or verified
21
- * material unless explicitly told to overwrite (an explicit, per-run,
22
- * user-facing opt-in — for services, a declared boolean `overwrite`
23
- * parameter).
24
- * 3. Any human edit of a machine annotation verifies it: the edit also
25
- * stamps { provConfirmed: true } (see verifyOnEdit).
25
+ * 2. Machine writers must never modify or delete human, contributed or
26
+ * verified material unless explicitly told to overwrite (an explicit,
27
+ * per-run, user-facing opt-in — for services, a declared boolean
28
+ * `overwrite` parameter). A contributor's work is a person's work.
29
+ * 3. A verifier's edit of machine or contributed material verifies it:
30
+ * the edit also stamps { provConfirmed: true } (see verifyOnEdit).
31
+ * A contributor's edit of anything marks it contributed: the edit
32
+ * merges stampContributed(userId) and drops any earlier confirmation
33
+ * (see contributeOnEdit). Who is a contributor is the PROJECT's call,
34
+ * recorded once for every app (see "Review" below); a service running
35
+ * as a contributor should stamp likewise.
36
+ *
37
+ * REVIEW: which people are contributors. A project records it under the
38
+ * reserved `plaid` config namespace, so every app that writes on a person's
39
+ * behalf reads the same answer:
40
+ *
41
+ * config.plaid.review = { users: [userId, ...], roles: ['writer', ...] }
42
+ *
43
+ * `users` names people whose work is reviewed whatever their role; `roles`
44
+ * names whole project roles ('reader' | 'writer' | 'maintainer'; an admin
45
+ * without an explicit role counts as a maintainer). Either may be absent.
46
+ * Nothing here grants or denies access: the ACL lists stay the permission
47
+ * model, and this only says whose work needs a verifier's look. Apps expose
48
+ * it as a per-member mark on their access screens (readReview, isReviewed,
49
+ * withReviewedUser) and derive the writer's policy from it (writerPolicy).
26
50
  *
27
51
  * Producer naming: 'service:<serviceId>' for services (use serviceSource),
28
- * 'rule:<name>' for built-in rule algorithms, app-specific ids like
29
- * 'gloss:doc-frequency' or 'flex-import' otherwise.
52
+ * 'rule:<name>' for built-in rule algorithms, 'user:<userId>' for a
53
+ * contributor (use userSource), app-specific ids like 'gloss:doc-frequency'
54
+ * or 'flex-import' otherwise.
30
55
  *
31
56
  * PREDICTION EXTRAS. A producer may also record how confident it was and
32
57
  * what else it considered, in two reserved slots split along the
@@ -43,6 +68,21 @@
43
68
  * human edits (history is valuable), so a consumer must not present
44
69
  * provProb as confidence in the CURRENT value once the entity is verified
45
70
  * (provConfirmed is exactly the flag to check).
71
+ *
72
+ * Recommended provDetail keys, so consumers (review dashboards, ranked
73
+ * alternatives) can read any producer's output:
74
+ * - value (spans) / form (morpheme tokens): the value the producer
75
+ * wrote, mirroring the entity's own field. The entity may be edited
76
+ * later; this copy is what makes "accepted as-is" vs "corrected"
77
+ * answerable once the entity is verified.
78
+ * - valueProbs / formProbs: a top-k {label: probability} map over the
79
+ * alternatives the producer considered, the chosen label included
80
+ * (provProb, when given, is that label's entry duplicated flat).
81
+ * - model: what made the prediction (a model name, or a tool and its
82
+ * release such as 'stanza==1.11.0'), and version: which version of the
83
+ * writer ran ('<release>+<8 hex>', see the manual, "Provenance"). Every
84
+ * machine writer records both, one of rules alone only version.
85
+ * - anything else (language, adapter, raw scores).
46
86
  */
47
87
 
48
88
  /** The flat metadata keys of the provenance convention. */
@@ -57,14 +97,36 @@ export const PROV = Object.freeze({
57
97
  probKey: 'provProb',
58
98
  /** Open map of producer extras (alternatives, model version, raw scores). */
59
99
  detailKey: 'provDetail',
60
- /** The (currently only) value of `prov`. Presence, not value, decides. */
100
+ /** `prov` for machine-made material. Any value but CONTRIBUTED reads as machine. */
61
101
  INFERRED: 'inferred',
102
+ /** `prov` for a contributor's material. */
103
+ CONTRIBUTED: 'contributed',
62
104
  });
63
105
 
64
- /** The three provenance states returned by provState. */
106
+ /**
107
+ * Every provenance key, for a caller that has to name them as a set rather
108
+ * than read one: what a token layer declares under
109
+ * `config.plaid.preserveOnSplit` so a token born of a split inherits the
110
+ * origin of the token it came from, and what a reshape carries by hand.
111
+ *
112
+ * Provenance is the one metadata family Plaid itself owns. The user cannot
113
+ * maintain it, and in a shared project usually cannot even see it, since it
114
+ * may belong to an app they are not using. Everything else on an entity is
115
+ * the user's own content and no structural operation should touch it.
116
+ */
117
+ export const PROVENANCE_KEYS = Object.freeze([
118
+ PROV.key,
119
+ PROV.sourceKey,
120
+ PROV.confirmedKey,
121
+ PROV.probKey,
122
+ PROV.detailKey,
123
+ ]);
124
+
125
+ /** The four provenance states returned by provState. */
65
126
  export const PROV_STATES = Object.freeze({
66
127
  HUMAN: 'human',
67
128
  MACHINE: 'machine',
129
+ CONTRIBUTED: 'contributed',
68
130
  VERIFIED: 'verified',
69
131
  });
70
132
 
@@ -105,35 +167,214 @@ export const confirmedInferred = (source, extras = {}) => ({
105
167
  */
106
168
  export const PROV_CONFIRMED = Object.freeze({ [PROV.confirmedKey]: true });
107
169
 
170
+ /**
171
+ * The metadata fragment a contributor's work carries:
172
+ * { prov: 'contributed', provSource: 'user:<userId>' }.
173
+ * @param {string} userId
174
+ */
175
+ export const stampContributed = (userId) => ({
176
+ [PROV.key]: PROV.CONTRIBUTED,
177
+ [PROV.sourceKey]: userSource(userId),
178
+ });
179
+
108
180
  /**
109
181
  * Classify an entity's metadata into one of PROV_STATES.
110
182
  * @param {Object|null|undefined} metadata
111
- * @returns {'human'|'machine'|'verified'}
183
+ * @returns {'human'|'machine'|'contributed'|'verified'}
112
184
  */
113
185
  export const provState = (metadata) => {
114
186
  if (!metadata || metadata[PROV.key] == null) return PROV_STATES.HUMAN;
115
- return metadata[PROV.confirmedKey] ? PROV_STATES.VERIFIED : PROV_STATES.MACHINE;
187
+ if (metadata[PROV.confirmedKey]) return PROV_STATES.VERIFIED;
188
+ return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV_STATES.CONTRIBUTED : PROV_STATES.MACHINE;
116
189
  };
117
190
 
118
191
  /**
119
- * Whether this entity is machine-made and not yet human-verified — the
120
- * material that still needs review, that machine writers may replace, and
121
- * that a human confirmation gesture acts on. Complement of isProtected.
192
+ * Where an entity came from, confirmed or not: null for a verifier's own
193
+ * work, PROV.INFERRED for a machine's, PROV.CONTRIBUTED for a contributor's.
194
+ * What a verified entity's tooltip needs, since provState folds both
195
+ * origins into 'verified'.
196
+ * @returns {null|'inferred'|'contributed'}
197
+ */
198
+ export const provOrigin = (metadata) => {
199
+ if (!metadata || metadata[PROV.key] == null) return null;
200
+ return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV.CONTRIBUTED : PROV.INFERRED;
201
+ };
202
+
203
+ /**
204
+ * Whether this entity is machine-made and not yet verified — the material
205
+ * machine writers may replace. Complement of isProtected.
122
206
  */
123
207
  export const isMachine = (metadata) => provState(metadata) === PROV_STATES.MACHINE;
124
208
 
125
209
  /**
126
210
  * Whether a machine writer must leave this entity alone (write-contract
127
- * rule 2): true for human-made and human-verified material.
211
+ * rule 2): true for human-made, contributed and verified material.
128
212
  */
129
213
  export const isProtected = (metadata) => !isMachine(metadata);
130
214
 
131
215
  /**
132
- * The metadata fragment a HUMAN edit of this entity should merge in
133
- * (write-contract rule 3): PROV_CONFIRMED when the entity is machine-made
134
- * and unverified, else null (nothing to do).
216
+ * Whether a verifier still has to look at this entity: machine-made or
217
+ * contributed, and not yet confirmed. What review UIs mark and sweep, and
218
+ * what a verifier's confirmation gesture acts on.
219
+ */
220
+ export const needsReview = (metadata) => {
221
+ const s = provState(metadata);
222
+ return s === PROV_STATES.MACHINE || s === PROV_STATES.CONTRIBUTED;
223
+ };
224
+
225
+ /**
226
+ * The metadata fragment a VERIFIER's edit of this entity should merge in
227
+ * (write-contract rule 3): PROV_CONFIRMED when the entity needs review,
228
+ * else null (nothing to do).
229
+ */
230
+ export const verifyOnEdit = (metadata) => (needsReview(metadata) ? PROV_CONFIRMED : null);
231
+
232
+ /**
233
+ * The metadata fragment a CONTRIBUTOR's edit of this entity should merge in
234
+ * (write-contract rule 3): the contributed stamp, plus provConfirmed: null
235
+ * so an earlier confirmation is dropped (send it with metadataOps, which
236
+ * turns a null into a delete, or apply it locally with mergeMetadata). Prediction extras a machine
237
+ * recorded stay, as history.
238
+ * @param {Object|null|undefined} metadata - unused today; the fragment is
239
+ * the same whatever the entity was, and the parameter keeps the shape of
240
+ * verifyOnEdit so callers can swap one for the other
241
+ * @param {string} userId
135
242
  */
136
- export const verifyOnEdit = (metadata) => (isMachine(metadata) ? PROV_CONFIRMED : null);
243
+ // eslint-disable-next-line no-unused-vars
244
+ export const contributeOnEdit = (metadata, userId) => ({
245
+ ...stampContributed(userId),
246
+ [PROV.confirmedKey]: null,
247
+ });
137
248
 
138
249
  /** Canonical provSource for a service: 'service:<serviceId>'. */
139
250
  export const serviceSource = (serviceId) => `service:${serviceId}`;
251
+
252
+ /** Canonical provSource for a contributor: 'user:<userId>'. */
253
+ export const userSource = (userId) => `user:${userId}`;
254
+
255
+ // ---- review: whose work is reviewed (a project-config norm) ----
256
+
257
+ /** The config key, under the `plaid` namespace, holding the review lists. */
258
+ export const REVIEW_KEY = 'review';
259
+
260
+ /** The project roles a review list may name. */
261
+ export const PROJECT_ROLES = Object.freeze(['reader', 'writer', 'maintainer']);
262
+
263
+ /**
264
+ * The project's review lists, normalized: { users: string[], roles: string[] }.
265
+ * @param {Object|null|undefined} config - a project's `config`
266
+ */
267
+ export const readReview = (config) => {
268
+ const raw = config?.plaid?.[REVIEW_KEY];
269
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
270
+ return { users: list(raw?.users), roles: list(raw?.roles) };
271
+ };
272
+
273
+ /**
274
+ * A person's role in a project from its ACL lists: 'maintainer' | 'writer' |
275
+ * 'reader' | null. An admin with no explicit entry is a maintainer, as the
276
+ * permission model treats them.
277
+ * @param {Object} project - with `maintainers`, `writers`, `readers`
278
+ * @param {string} userId
279
+ * @param {Object} [opts]
280
+ * @param {boolean} [opts.isAdmin]
281
+ */
282
+ export const projectRole = (project, userId, { isAdmin = false } = {}) => {
283
+ const inList = (l) => Array.isArray(l) && userId != null && l.includes(userId);
284
+ if (inList(project?.maintainers)) return 'maintainer';
285
+ if (inList(project?.writers)) return 'writer';
286
+ if (inList(project?.readers)) return 'reader';
287
+ return isAdmin ? 'maintainer' : null;
288
+ };
289
+
290
+ /**
291
+ * Whether this person's work is reviewed in this project: named in
292
+ * review.users, or holding a role named in review.roles.
293
+ */
294
+ export const isReviewed = (project, userId, { isAdmin = false } = {}) => {
295
+ if (!project || userId == null) return false;
296
+ const { users, roles } = readReview(project.config);
297
+ if (users.includes(userId)) return true;
298
+ const role = projectRole(project, userId, { isAdmin });
299
+ return role != null && roles.includes(role);
300
+ };
301
+
302
+ /**
303
+ * The review lists with one person added to or removed from `users`
304
+ * (`roles` untouched). Pure; write the result with
305
+ * projects.setConfig(id, 'plaid', REVIEW_KEY, next).
306
+ */
307
+ export const withReviewedUser = (review, userId, reviewed) => {
308
+ const { users, roles } = readReview({ plaid: { [REVIEW_KEY]: review } });
309
+ const next = users.filter((u) => u !== userId);
310
+ if (reviewed) next.push(userId);
311
+ return { users: next, roles };
312
+ };
313
+
314
+ // ---- the writer's policy: what a person's writes carry ----
315
+
316
+ /**
317
+ * What one writer's writes carry and what their review gestures act on,
318
+ * given who they are: `contributorId` is their user id when their work is
319
+ * reviewed (isReviewed), else null for a verifier. Apps derive this once per
320
+ * document and route every human write through it, so the convention's
321
+ * rule 3 has one implementation.
322
+ *
323
+ * @param {string|null} contributorId
324
+ * @returns {{
325
+ * contributorId: string|null,
326
+ * isContributor: boolean,
327
+ * createStamp: Object|null,
328
+ * editStamp: (metadata: Object) => Object|null,
329
+ * confirmStamp: (metadata: Object) => Object|null,
330
+ * adoptStamp: (source: string, detail?: Object) => Object,
331
+ * reviewable: (metadata: Object) => boolean,
332
+ * reviewableState: (state: string) => boolean,
333
+ * }}
334
+ */
335
+ export const writerPolicy = (contributorId = null) => {
336
+ const id = contributorId || null;
337
+ const contributor = id != null;
338
+ // Material this writer's review gestures act on: a verifier reviews
339
+ // machine and contributed material; a contributor reviews machine
340
+ // proposals only, since their own vouching is itself a contribution.
341
+ const reviewable = (metadata) => (contributor ? isMachine(metadata) : needsReview(metadata));
342
+ const reviewableState = (state) =>
343
+ contributor
344
+ ? state === PROV_STATES.MACHINE
345
+ : state === PROV_STATES.MACHINE || state === PROV_STATES.CONTRIBUTED;
346
+ return Object.freeze({
347
+ contributorId: id,
348
+ isContributor: contributor,
349
+ /** The metadata a NEW entity carries: null for a verifier. */
350
+ createStamp: contributor ? stampContributed(id) : null,
351
+ /**
352
+ * The fragment an EDIT merges over the entity's metadata, or null when
353
+ * there is nothing to merge: a verifier confirms what needs review; a
354
+ * contributor's edit marks the entity contributed, dropping any earlier
355
+ * confirmation.
356
+ */
357
+ editStamp: (metadata) => (contributor ? contributeOnEdit(metadata, id) : verifyOnEdit(metadata)),
358
+ /**
359
+ * The fragment an explicit confirm gesture merges, or null when there is
360
+ * nothing for this writer to confirm: PROV_CONFIRMED for a verifier, the
361
+ * contributed stamp for a contributor accepting a machine proposal.
362
+ */
363
+ confirmStamp: (metadata) => {
364
+ if (!reviewable(metadata)) return null;
365
+ return contributor ? contributeOnEdit(metadata, id) : PROV_CONFIRMED;
366
+ },
367
+ /**
368
+ * What an adopted suggestion is written with (a guess or a picked value
369
+ * into an empty cell): born-verified with the suggestion's producer as
370
+ * its source for a verifier; contributed for a contributor, with the
371
+ * producer kept as provDetail.guess. `detail` is the prediction extras.
372
+ */
373
+ adoptStamp: (source, detail) =>
374
+ contributor
375
+ ? { ...stampContributed(id), [PROV.detailKey]: { ...(detail || {}), guess: source } }
376
+ : confirmedInferred(source, { detail }),
377
+ reviewable,
378
+ reviewableState,
379
+ });
380
+ };
@@ -0,0 +1,27 @@
1
+ // A write sent under an Idempotency-Key whose request the server had already
2
+ // run (its first answer was lost, and the client sent it again) is answered
3
+ // from what that first send stored, with `Idempotent-Replayed: true`, and
4
+ // writes nothing. The client resends on its own (retryUnknown), so the header
5
+ // never reaches the caller. It is marked on the answer instead: `replayed`,
6
+ // true and not enumerable, so the answer reads, compares and serializes as it
7
+ // would unmarked. Only an answer with a JSON object or list body can carry it.
8
+ // Imports nothing.
9
+
10
+ /** `value` marked as replayed, when it is an object or a list. */
11
+ export function markReplayed(value) {
12
+ if (value !== null && typeof value === "object") {
13
+ Object.defineProperty(value, "replayed", {
14
+ value: true,
15
+ enumerable: false,
16
+ configurable: true,
17
+ });
18
+ }
19
+ return value;
20
+ }
21
+
22
+ /** Whether a client answer was replayed from its key's first send. */
23
+ export const wasReplayed = (answer) =>
24
+ answer !== null &&
25
+ typeof answer === "object" &&
26
+ Object.getOwnPropertyDescriptor(answer, "replayed")?.enumerable === false &&
27
+ answer.replayed === true;
@@ -0,0 +1,56 @@
1
+ // Who asked for a service run, named in what the run leaves behind.
2
+ //
3
+ // A plain service writes with its own token, so the History entry of a run
4
+ // reads "by <operator> (via <token>)" and the person who pressed the button is
5
+ // recorded nowhere. Core tells every service who asked (`requesterId` in the
6
+ // request data), and every app's services put that person in the History
7
+ // label and in what they store:
8
+ //
9
+ // AnCast adjudication against lunch, requested by second
10
+ //
11
+ // This is the JS twin of `plaid_client.workflows.requester`, for a service
12
+ // authored with `serve`. The wording and the stored key are the same.
13
+ //
14
+ // The name is the requester's display name, which any signed-in user may
15
+ // read. When it cannot be read the id stands in for it, and with no requester
16
+ // at all the label is left as it was and nothing is stored.
17
+
18
+ /** The key a run's stored record names its requester under. */
19
+ export const REQUESTED_BY = "requestedBy";
20
+
21
+ /**
22
+ * The person who asked for one run: `id` is their account (their email),
23
+ * `name` how they are shown. Both are null when nobody asked.
24
+ */
25
+ export function makeRequester(id = null, name = null) {
26
+ return {
27
+ id,
28
+ name,
29
+ /** A History label with the requester named, or `text` when nobody asked. */
30
+ label: (text) => (name ? `${text}, requested by ${name}` : text),
31
+ /** `{ id, name }` for a stored report, or null when nobody asked. */
32
+ record: () => (id ? { id, name } : null),
33
+ /** A machine stamp's `provDetail` with the requester's id added. */
34
+ detail: (detail = {}) => ({ ...(detail || {}), ...(id ? { [REQUESTED_BY]: id } : {}) }),
35
+ };
36
+ }
37
+
38
+ /**
39
+ * The requester of one request, from the `requesterId` core put in its data,
40
+ * with their display name read through `client`.
41
+ * @param {object} client - A PlaidClient (only `users.get` is used)
42
+ * @param {object} data - The request data a `serve` handler receives
43
+ */
44
+ export async function requesterOf(client, data) {
45
+ const id = data && typeof data === "object" ? data.requesterId || null : null;
46
+ if (!id) return makeRequester();
47
+ let name = null;
48
+ try {
49
+ name = (await client.users.get(id))?.displayName || null;
50
+ } catch (error) {
51
+ // The id is always there to fall back on, so a failed read costs the
52
+ // label nothing but the nicer name.
53
+ console.warn(`Could not read the requester ${id}: ${error?.message || error}`);
54
+ }
55
+ return makeRequester(id, name || id);
56
+ }