Skip to content

Commit 6283216

Browse files
committed
Merge feat/lib-3.1.0: Plan 58 pryv@3.1.0 + accesses.update rollout
2 parents 7e44888 + 7261d55 commit 6283216

11 files changed

Lines changed: 216 additions & 192 deletions

package-lock.json

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,11 @@
6262
"webpack-cli": "^6.0.1"
6363
},
6464
"dependencies": {
65-
"@pryv/monitor": "3.0.3",
66-
"@pryv/socket.io": "3.0.3",
65+
"@pryv/monitor": "3.1.0",
66+
"@pryv/socket.io": "3.1.0",
6767
"ajv": "^8.20.0",
6868
"events": "^3.3.0",
69-
"pryv": "3.0.4",
69+
"pryv": "3.1.0",
7070
"short-unique-id": "^5.3.2"
7171
}
7272
}

tests/apptemplates.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,8 +382,8 @@ describe('[APTX] appTemplates', function () {
382382
assert.equal(invite.errorType, 'revoked');
383383

384384
// check if authorization is revoked
385-
386-
const res = await invite.connection.accessInfo();
385+
// pryv@3.1.0 caches accessInfo per Connection — pass forceRefresh to bypass the cache after revoke
386+
const res = await invite.connection.accessInfo(true);
387387
assert.equal(res.error.id, 'invalid-access-token');
388388
});
389389

tests/bridgeAccess.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,19 @@ import { assert } from './test-utils/deps-node.js';
55
// The main integration test is in the bridge-mira tests.
66

77
// Import to verify the module loads correctly
8-
import { getOrCreateBridgeAccess, recreateBridgeAccess, ensureBridgeAccess } from '../ts/appTemplates/bridgeAccess.ts';
8+
import { getOrCreateBridgeAccess, ensureBridgeAccess } from '../ts/appTemplates/bridgeAccess.ts';
99

1010
describe('[BACC] Bridge Access helpers', function () {
11-
it('[BA01] should export all three helper functions', () => {
11+
it('[BA01] should export both helper functions', () => {
1212
assert.equal(typeof getOrCreateBridgeAccess, 'function');
13-
assert.equal(typeof recreateBridgeAccess, 'function');
1413
assert.equal(typeof ensureBridgeAccess, 'function');
1514
});
1615

1716
it('[BA02] should be importable from appTemplates', async () => {
1817
const appTemplates = await import('../ts/appTemplates/appTemplates.ts');
1918
assert.equal(typeof appTemplates.getOrCreateBridgeAccess, 'function');
20-
assert.equal(typeof appTemplates.recreateBridgeAccess, 'function');
2119
assert.equal(typeof appTemplates.ensureBridgeAccess, 'function');
20+
// recreateBridgeAccess was dropped in Plan 58 Phase 4b — accesses.update replaces delete+create.
21+
assert.equal(typeof appTemplates.recreateBridgeAccess, 'undefined');
2222
});
2323
});

tests/contact.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,21 @@ describe('[CTCT] Contact class', function () {
302302
assert.equal(c.eventIsFromContact({ modifiedBy: 'a2-older' }), true);
303303
assert.equal(c.eventIsFromContact({ modifiedBy: 'unknown' }), false);
304304
});
305+
306+
it('[CTAQ3] should match across Plan 66 composite ids (base-only equality)', () => {
307+
const c = new Contact('u', 'U');
308+
// Updated access serialises as composite `<base>:<serial>` on the wire
309+
c.addAccessObject({ id: 'a1:2' });
310+
// Event written when the access was at serial 0 (bare cuid)
311+
assert.equal(c.eventIsFromContact({ modifiedBy: 'a1' }), true);
312+
// Event written when the access was at serial 1 (composite, lower serial)
313+
assert.equal(c.eventIsFromContact({ modifiedBy: 'a1:1' }), true);
314+
// Event written at current serial
315+
assert.equal(c.eventIsFromContact({ modifiedBy: 'a1:2' }), true);
316+
// Different base → no match
317+
assert.equal(c.eventIsFromContact({ modifiedBy: 'a2' }), false);
318+
assert.equal(c.eventIsFromContact({ modifiedBy: 'a2:2' }), false);
319+
});
305320
});
306321

307322
// ---- sourceFromAccess (static) ---- //

ts/appTemplates/Collector.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,14 @@ export class Collector {
213213
};
214214
updateInvite.content.sourceEventId = responseEvent.id;
215215

216+
// Plan 58 Phase 4d: legacy update-accept events (pre-Plan-58, delete+create era)
217+
// carry an apiEndpoint field that must be adopted (token rotation happened).
218+
// New update-accept events omit apiEndpoint (token preserved by accesses.update).
219+
// When archiving a legacy event, opportunistically strip apiEndpoint from its
220+
// content so the archive stays clean. events.update REPLACES content (not merges),
221+
// so the strip is just "write current content minus the field".
222+
let stripLegacyApiEndpointFromResponse = false;
223+
216224
// check type of response
217225
switch (responseEvent.content.type) {
218226
case 'accept':
@@ -222,9 +230,13 @@ export class Collector {
222230
if (responseEvent.content.system) updateInvite.content.system = responseEvent.content.system;
223231
break;
224232
case 'update-accept':
225-
// Patient accepted an access update — new apiEndpoint, stays active
226233
(updateInvite as any).streamIds = [this.streamIdFor(Collector.STREAMID_SUFFIXES.active)];
227-
updateInvite.content.apiEndpoint = responseEvent.content.apiEndpoint;
234+
// Legacy events: adopt the rotated apiEndpoint and flag the response for migration.
235+
// New events: omit the field; the stored apiEndpoint stays valid in place.
236+
if (responseEvent.content.apiEndpoint != null) {
237+
updateInvite.content.apiEndpoint = responseEvent.content.apiEndpoint;
238+
stripLegacyApiEndpointFromResponse = true;
239+
}
228240
if (responseEvent.content.chat) updateInvite.content.chat = responseEvent.content.chat;
229241
if (responseEvent.content.system) updateInvite.content.system = responseEvent.content.system;
230242
break;
@@ -244,6 +256,15 @@ export class Collector {
244256
throw new HDSLibError(`Unkown or undefined ${responseEvent.content.type}`, responseEvent);
245257
}
246258

259+
// Archive update — also strip legacy apiEndpoint when applicable.
260+
const responseArchiveUpdate: { streamIds: string[], content?: any } = {
261+
streamIds: [this.streamIdFor(Collector.STREAMID_SUFFIXES.archive)]
262+
};
263+
if (stripLegacyApiEndpointFromResponse) {
264+
const { apiEndpoint: _stripped, ...cleanedContent } = responseEvent.content;
265+
responseArchiveUpdate.content = cleanedContent;
266+
}
267+
247268
// update inviteEvent and archive inbox message
248269
const apiCalls = [
249270
{
@@ -257,9 +278,7 @@ export class Collector {
257278
method: 'events.update',
258279
params: {
259280
id: responseEvent.id,
260-
update: {
261-
streamIds: [this.streamIdFor(Collector.STREAMID_SUFFIXES.archive)]
262-
}
281+
update: responseArchiveUpdate
263282
}
264283
}
265284
];

ts/appTemplates/CollectorClient.ts

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -511,12 +511,26 @@ export class CollectorClient {
511511
}
512512

513513
/**
514-
* Accept a pending update request: delete old access, create new one with updated permissions,
515-
* notify requester via inbox with new apiEndpoint.
514+
* Accept a pending update request: update the access in place via accesses.update
515+
* (Plan 66), notify requester via inbox.
516+
*
517+
* The access id, token and apiEndpoint are preserved across the update; only
518+
* the wire id becomes composite (`<base>:<serial>`). The requester does not
519+
* need to re-store an apiEndpoint.
520+
*
521+
* `clientData` is intentionally NOT sent: the server merges (verified empirically
522+
* on Plan 66 demo) so existing keys including legacy `hdsCollectorClient.previousAccessIds`
523+
* chains stay intact. The current `hdsCollectorClient.eventData` snapshot remains
524+
* valid because `this.eventData` is unchanged at update time.
525+
*
526+
* `StaleAccessIdError` handling: if another writer updated the access between
527+
* the load of `pendingUpdate` and now, refetch the access by name and retry
528+
* once. Two consecutive stale errors propagate.
516529
*/
517530
async acceptUpdate (): Promise<{ accessData: any, requesterEvent: any } | null> {
518531
if (!this.pendingUpdate) throw new HDSLibError('No pending update to accept');
519532
if (this.status !== CollectorClient.STATUSES.active) throw new HDSLibError('Can only accept updates on active CollectorClients');
533+
if (!this.accessData?.id) throw new HDSLibError('No access to update', this.accessData);
520534

521535
const update = this.pendingUpdate.content;
522536

@@ -527,7 +541,7 @@ export class CollectorClient {
527541
});
528542

529543
// Handle chat feature if requested
530-
const responseContent: { apiEndpoint?: string, chat?: any } = {};
544+
const responseContent: { chat?: any } = {};
531545
if (update.features?.chat && !this.hasChatFeature) {
532546
const chatStreamMain = `chat-${this.requesterUsername}`;
533547
const chatStreamIncoming = `chat-${this.requesterUsername}-in`;
@@ -559,44 +573,30 @@ export class CollectorClient {
559573
);
560574
}
561575

562-
// Collect previous access IDs for event attribution (modifiedBy tracking)
563-
const previousAccessIds: string[] = [];
564-
if (this.accessData) {
565-
if (this.accessData.id) previousAccessIds.push(this.accessData.id);
566-
// Chain: carry forward any IDs from the old access's clientData
567-
const oldPrevIds = this.accessData.clientData?.hdsCollectorClient?.previousAccessIds;
568-
if (Array.isArray(oldPrevIds)) {
569-
for (const id of oldPrevIds) {
570-
if (!previousAccessIds.includes(id)) previousAccessIds.push(id);
576+
// Update access in place. 1-retry on StaleAccessIdError.
577+
let attempt = 0;
578+
while (true) {
579+
try {
580+
const updated = await (this.app.connection as any).updateAccess(this.accessData.id, {
581+
permissions: cleanedPermissions
582+
});
583+
this.accessData = updated;
584+
break;
585+
} catch (e: any) {
586+
if (e instanceof (pryv as any).StaleAccessIdError && attempt === 0) {
587+
attempt++;
588+
// Refetch the access by name (composite serial may have advanced)
589+
const accesses = await this.app.connection.apiOne('accesses.get', {}, 'accesses');
590+
const fresh = accesses.find((a: any) => a.name === this.key);
591+
if (!fresh) throw new HDSLibError('Access disappeared during update', accesses);
592+
this.accessData = fresh;
593+
continue;
571594
}
595+
throw e;
572596
}
573597
}
574598

575-
// Delete old access
576-
if (this.accessData && !this.accessData.deleted) {
577-
await this.app.connection.apiOne('accesses.delete', { id: this.accessData.id }, 'accessDeletion');
578-
}
579-
580-
// Create new access with updated permissions
581-
const accessCreateData = {
582-
name: this.key,
583-
type: 'shared',
584-
permissions: cleanedPermissions,
585-
clientData: {
586-
hdsCollectorClient: {
587-
version: 0,
588-
eventData: this.eventData,
589-
previousAccessIds
590-
}
591-
}
592-
};
593-
const accessData = await this.app.connection.apiOne('accesses.create', accessCreateData, 'access');
594-
this.accessData = accessData;
595-
if (!this.accessData?.apiEndpoint) throw new HDSLibError('Failed creating updated access', accessData);
596-
597-
responseContent.apiEndpoint = this.accessData.apiEndpoint;
598-
599-
// Notify requester via inbox
599+
// Notify requester via inbox. No `apiEndpoint` field — token is preserved.
600600
const requesterEvent = await this.#updateRequester('update-accept', responseContent);
601601
if (requesterEvent != null) {
602602
this.pendingUpdate = null;

ts/appTemplates/CollectorInvite.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,16 @@ export class CollectorInvite {
160160
/**
161161
* Check if connection is valid. (only if active)
162162
* If result is "forbidden" update and set as revoked
163-
* @returns accessInfo if valid.
163+
*
164+
* Caching is delegated to `pryv.Connection.accessInfo()` (per-Connection cache
165+
* introduced in pryv@3.1.0). Pass `forceRefresh=true` to bust the cache —
166+
* required after operations that invalidate the access (revoke, delete).
167+
*
168+
* The `#accessInfo` field mirrors the result for the sync `patientUsername` getter.
164169
*/
165170
async checkAndGetAccessInfo (forceRefresh: boolean = false): Promise<any> {
166-
if (!forceRefresh && this.#accessInfo) return this.#accessInfo;
167171
try {
168-
this.#accessInfo = await this.connection.accessInfo();
172+
this.#accessInfo = await this.connection.accessInfo(forceRefresh);
169173
return this.#accessInfo;
170174
} catch (e: any) {
171175
this.#accessInfo = null;

ts/appTemplates/Contact.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ import { HDSModelAppStreams } from '../HDSModel/HDSModel-AppStreams.ts';
66
import { getStreamIdAndChildrenIds } from '../toolkit/StreamsTools.ts';
77
import { pryv } from '../patchedPryv.ts';
88

9+
/**
10+
* Plan 66 composite-id base extractor. Refs serialise as either bare cuid
11+
* (`"abc123"`) for never-updated accesses or composite (`"abc123:3"`) for
12+
* updated heads / historical writes. Equality must be on the *base* — an
13+
* event's `modifiedBy` may carry a serial different from the current
14+
* access head, but it still attributes to the same access chain.
15+
*
16+
* Returns the input unchanged when `parseAccessRef` is unavailable
17+
* (older pryv lib), letting callers keep working under pre-Plan-66 servers.
18+
*/
19+
function refBase (ref: string | null | undefined): string | null {
20+
if (ref == null) return null;
21+
const parse = (pryv as any).utils?.parseAccessRef;
22+
if (typeof parse !== 'function') return ref;
23+
try {
24+
return parse(ref).base;
25+
} catch {
26+
return null;
27+
}
28+
}
29+
930
/** Doctor-side: a form invite pair (which collector + which invite) */
1031
export interface ContactInvite {
1132
collector: Collector;
@@ -141,14 +162,28 @@ export class Contact {
141162

142163
/** Check if an event was created/modified by this contact (including replaced accesses) */
143164
eventIsFromContact (event: pryv.Event): boolean {
165+
const eventBase = refBase(event.modifiedBy);
166+
if (eventBase == null) return false;
144167
for (const access of this.accessObjects) {
145-
if (access.id && event.modifiedBy === access.id) return true;
146-
// Check previous access IDs from replaced accesses (collector pattern)
168+
// Plan 66: access.id may be composite (`<base>:<serial>`) on updated accesses.
169+
// An event's modifiedBy carries the serial active at write time, possibly
170+
// different from the current head. Compare on base only.
171+
if (refBase(access.id) === eventBase) return true;
172+
// Check previous access IDs from replaced accesses (collector pattern, legacy delete+create).
173+
// The historical chain stays in clientData even after Plan 58 switches to in-place update.
147174
const collectorPrevIds = access.clientData?.hdsCollectorClient?.previousAccessIds;
148-
if (Array.isArray(collectorPrevIds) && collectorPrevIds.includes(event.modifiedBy)) return true;
149-
// Check previous access IDs from bridge access recreate pattern
175+
if (Array.isArray(collectorPrevIds)) {
176+
for (const prev of collectorPrevIds) {
177+
if (refBase(prev) === eventBase) return true;
178+
}
179+
}
180+
// Check previous access IDs from bridge access recreate pattern (legacy)
150181
const bridgePrevIds = access.clientData?.previousAccessIds;
151-
if (Array.isArray(bridgePrevIds) && bridgePrevIds.includes(event.modifiedBy)) return true;
182+
if (Array.isArray(bridgePrevIds)) {
183+
for (const prev of bridgePrevIds) {
184+
if (refBase(prev) === eventBase) return true;
185+
}
186+
}
152187
}
153188
return false;
154189
}

ts/appTemplates/appTemplates.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CollectorRequest } from './CollectorRequest.ts';
88
import { Contact } from './Contact.ts';
99
export type { ContactInvite } from './Contact.ts';
1010
export type { AccessUpdateRequest, AccessUpdateRequestContent, AccessUpdateAction } from './interfaces.ts';
11-
export { getOrCreateBridgeAccess, recreateBridgeAccess, ensureBridgeAccess } from './bridgeAccess.ts';
11+
export { getOrCreateBridgeAccess, ensureBridgeAccess } from './bridgeAccess.ts';
1212
export type { BridgeAccessOptions, BridgeAccessResult } from './bridgeAccess.ts';
1313
export {
1414
getSectionItemLabels,

0 commit comments

Comments
 (0)