@mdbase-dev/connect 0.1.0-beta.112 → 0.1.0-beta.114

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.
@@ -1 +1 @@
1
- sha384-uKL9FxAY8Ji2mQSA44JTIIarA2CnPGN3EFC4jxk1Kx+OfDsPbFr2J+iwlH/p77Qz
1
+ sha384-sKQCnPwTnFWppCgBKTA5C2uhsWBDg58z/gsNhfn1DzzGGacp81eusoIOCMziHKxe
@@ -1,3 +1,4 @@
1
+ import { MdbasePeopleClient } from "./people-client.js";
1
2
  import type { CollectionOperation, CollectionTypeDocument, FileCapability, GrantScope, JsonObject } from "@mdbase-dev/connect-protocol";
2
3
  import { type MdbaseApplicationCapabilityId as ApplicationCapabilityId } from "./application-contract.js";
3
4
  import { type ConnectionTransportInternals } from "./connection-transport.js";
@@ -77,6 +78,7 @@ export declare class MdbaseConnection<Frontmatter extends JsonObject = JsonObjec
77
78
  readonly files: MdbaseFileClient;
78
79
  /** Editable record sessions shared by every view of this connection. */
79
80
  readonly records: MdbaseRecords<Frontmatter>;
81
+ readonly people: MdbasePeopleClient;
80
82
  private readonly connectionListeners;
81
83
  constructor(internals: MdbaseConnectionInternals<Frontmatter>, collectionId: string);
82
84
  get displayName(): string;
@@ -1,3 +1,4 @@
1
+ import { MdbasePeopleClient, requestPeople } from "./people-client.js";
1
2
  import { capabilityOperationsForContractVersion } from "@mdbase-dev/connect-protocol";
2
3
  import { retainedCapabilities, semanticContractVersion, validateAuthorizationSelection } from "./application-contract.js";
3
4
  import { MdbaseCollectionClient } from "./collection-client.js";
@@ -20,6 +21,7 @@ export class MdbaseConnection {
20
21
  files;
21
22
  /** Editable record sessions shared by every view of this connection. */
22
23
  records;
24
+ people;
23
25
  connectionListeners = new Set();
24
26
  constructor(internals, collectionId) {
25
27
  this.internals = internals;
@@ -35,6 +37,14 @@ export class MdbaseConnection {
35
37
  timeouts: internals.timeouts,
36
38
  onChange: () => this.emitConnection()
37
39
  });
40
+ this.people = new MdbasePeopleClient({
41
+ request: (resource, options) => requestPeople(resource, options ?? {}, {
42
+ serverUrl: internals.serverUrl, collectionId, requestMs: internals.timeouts.requestMs,
43
+ authorizedToken: (signal) => this.transport.authorizedToken({ signal, timeoutMs: null })
44
+ }),
45
+ describe: (options) => this.describe(options),
46
+ queryPages: (input, options) => this.queryPages(input, options)
47
+ });
38
48
  this.files = new MdbaseFileClient(() => this.fileCapability, (method, path, input, signal) => this.transport.files.control(method, path, input, signal), {
39
49
  uploadChunk: (session, chunkIndex, bytes, signal) => this.transport.files.uploadChunk(session, chunkIndex, bytes, signal),
40
50
  downloadChunk: (session, chunkIndex, signal) => this.transport.files.downloadChunk(session, chunkIndex, signal)
package/dist/index.d.ts CHANGED
@@ -15,6 +15,9 @@ export { MdbaseRecords } from "./records.js";
15
15
  export type { MdbaseRecordLease, MdbaseRecordOpenOptions } from "./records.js";
16
16
  export { MdbaseRecordSession } from "./record-session.js";
17
17
  export type { MdbaseRecordResolution, MdbaseRecordSessionOptions, MdbaseRecordSessionSnapshot, MdbaseRecordSessionState } from "./record-session.js";
18
+ export { MdbasePeopleClient, PERSON_CONTRACT, sameIdentity } from "./people-client.js";
19
+ export type { CurrentAccount, InvalidPersonRecord, PeopleDirectory, PeopleDirectoryOptions, PersonRecord, PersonResolution } from "./people-client.js";
20
+ export type { AccountIdentity, AccountProfile, CollectionMemberProfile, ApplicationPeopleRequirement, PeoplePermission } from "@mdbase-dev/connect-protocol";
18
21
  export type { MdbaseFileSource, CollectionFileDescriptor, MdbaseFileDeleteReceipt, MdbaseFileStreamSource, MdbaseFileProgress, MdbaseFileListOptions, MdbaseFileUploadOptions, MdbaseFileStreamUploadOptions, MdbaseFileDownloadOptions, MdbaseFileMoveOptions, MdbaseFileDeleteOptions } from "./files.js";
19
22
  export { parseMdbasePushPayload, parseMdbaseNativeNotificationData, showMdbasePushNotification } from "./notifications.js";
20
23
  export type { MdbaseNotificationRegistrationOptions, MdbaseNotificationRegistration, MdbaseNativeNotificationRegistrationOptions, MdbaseNativeNotificationRegistration, MdbaseNativeNotificationData, MdbasePushPayload } from "./notifications.js";
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { MdbaseConnectError, isRetryableConnectError } from "./errors.js";
6
6
  export { MdbaseFileClient } from "./files.js";
7
7
  export { MdbaseRecords } from "./records.js";
8
8
  export { MdbaseRecordSession } from "./record-session.js";
9
+ export { MdbasePeopleClient, PERSON_CONTRACT, sameIdentity } from "./people-client.js";
9
10
  export { parseMdbasePushPayload, parseMdbaseNativeNotificationData, showMdbasePushNotification } from "./notifications.js";
10
11
  export { externalStore } from "./external-store.js";
11
12
  export { MdbaseBrowserSelection, MdbaseMemorySelection } from "./selection.js";
@@ -60,6 +60,8 @@ export interface QueryRecord<Frontmatter extends JsonObject = JsonObject> {
60
60
  file: Partial<CollectionFileMetadata> & {
61
61
  path?: string;
62
62
  };
63
+ /** Selected and projected values, keyed by output name, when the query used `select`. */
64
+ values?: JsonObject;
63
65
  contract?: DataContractViewIdentity;
64
66
  }
65
67
  export interface RecordDocument<Frontmatter extends JsonObject = JsonObject> {
@@ -0,0 +1,90 @@
1
+ import type { AccountIdentity, AccountProfile, CollectionMemberProfile } from "@mdbase-dev/connect-protocol";
2
+ import type { CollectionDescription, ConnectRequestOptions, QueryInput, QueryPage, QueryPagesOptions } from "./operation-types.js";
3
+ import { type CollectionQueryProblemCode, type CommonOperationProblemCode, type ConnectOutcome } from "./outcomes.js";
4
+ export declare const PERSON_CONTRACT: {
5
+ readonly id: "mdbase.person";
6
+ readonly version: "2.0.0";
7
+ };
8
+ /** The authenticated account, plus where it manages its person record. */
9
+ export interface CurrentAccount extends AccountProfile {
10
+ /** A navigation route that may change; never derive one from `issuer`. */
11
+ personSettingsUrl?: string;
12
+ }
13
+ /** One record's normalized `mdbase.person` projection. Other records refer to it by link. */
14
+ export interface PersonRecord {
15
+ path: string;
16
+ name: string;
17
+ identities: AccountIdentity[];
18
+ /** Every type through which this record implements the Person contract. */
19
+ typeNames: string[];
20
+ }
21
+ /** A Person record that could not be projected; reported, never guessed. */
22
+ export interface InvalidPersonRecord {
23
+ path: string;
24
+ reason: string;
25
+ }
26
+ /**
27
+ * How the current account resolves to a person record. Resolution matches
28
+ * exact issuer/subject pairs only: no normalization, email, or name matching.
29
+ */
30
+ export type PersonResolution = {
31
+ status: "linked";
32
+ person: PersonRecord;
33
+ } | {
34
+ status: "unlinked";
35
+ }
36
+ /** Several records claim this account. */
37
+ | {
38
+ status: "ambiguous";
39
+ paths: string[];
40
+ }
41
+ /** A record that fails projection claims this account; fix it before relying on it. */
42
+ | {
43
+ status: "invalid";
44
+ paths: string[];
45
+ };
46
+ export interface PeopleDirectory {
47
+ account: CurrentAccount;
48
+ me: PersonResolution;
49
+ people: PersonRecord[];
50
+ invalid: InvalidPersonRecord[];
51
+ /** Absent when not requested or not approved; never an empty stand-in. */
52
+ members?: CollectionMemberProfile[];
53
+ }
54
+ export interface PeopleDirectoryOptions extends ConnectRequestOptions {
55
+ /**
56
+ * `"if-approved"` includes members when the grant allows it and omits them
57
+ * when the user declined that optional permission.
58
+ */
59
+ members?: "require" | "if-approved" | "omit";
60
+ }
61
+ export declare function requestPeople(resource: "identity" | "members", options: ConnectRequestOptions, context: {
62
+ serverUrl: string;
63
+ collectionId: string;
64
+ requestMs: number | null;
65
+ authorizedToken(signal: AbortSignal): Promise<{
66
+ accessToken: string;
67
+ } | null>;
68
+ }): Promise<unknown>;
69
+ export interface PeopleClientContext {
70
+ request(resource: "identity" | "members", options?: ConnectRequestOptions): Promise<unknown>;
71
+ describe(options?: ConnectRequestOptions): Promise<ConnectOutcome<CollectionDescription, CollectionQueryProblemCode>>;
72
+ queryPages(input: QueryInput, options: QueryPagesOptions): AsyncIterable<ConnectOutcome<QueryPage, CollectionQueryProblemCode>>;
73
+ }
74
+ /** Account metadata and the collection's person records, resolved one way for every app. */
75
+ export declare class MdbasePeopleClient {
76
+ private readonly context;
77
+ constructor(context: PeopleClientContext);
78
+ current(options?: ConnectRequestOptions): Promise<ConnectOutcome<CurrentAccount, CommonOperationProblemCode>>;
79
+ members(options?: ConnectRequestOptions): Promise<ConnectOutcome<CollectionMemberProfile[], CommonOperationProblemCode>>;
80
+ /**
81
+ * Reads every record of every type implementing `mdbase.person` 2.0.0, across
82
+ * all pages, and resolves the current account. Any failed read fails the
83
+ * whole directory: a partial directory is never presented as complete.
84
+ */
85
+ directory(options?: PeopleDirectoryOptions): Promise<ConnectOutcome<PeopleDirectory, CollectionQueryProblemCode>>;
86
+ private readCurrent;
87
+ private readMembers;
88
+ private readRecords;
89
+ }
90
+ export declare function sameIdentity(left: AccountIdentity, right: AccountIdentity): boolean;
@@ -0,0 +1,177 @@
1
+ import { connectError, MdbaseConnectError } from "./errors.js";
2
+ import { connectFetch, decodeJsonResponse } from "./runtime-utils.js";
3
+ import { withRequestBudget } from "./request-budget.js";
4
+ import { captureConnectOutcome, COLLECTION_QUERY_PROBLEM_CODES, COMMON_OPERATION_PROBLEM_CODES } from "./outcomes.js";
5
+ export const PERSON_CONTRACT = { id: "mdbase.person", version: "2.0.0" };
6
+ export function requestPeople(resource, options, context) {
7
+ return withRequestBudget(options, context.requestMs, async (budget) => {
8
+ const token = await context.authorizedToken(budget.signal);
9
+ if (!token)
10
+ throw connectError("not_authorized", "Authorize this collection before reading people.");
11
+ const response = await connectFetch(`${context.serverUrl}/v1/authorities/${encodeURIComponent(context.collectionId)}/${resource}`, { headers: { authorization: `Bearer ${token.accessToken}` }, signal: budget.signal, credentials: "omit", cache: "no-store", redirect: "error" }, "temporarily_unavailable", "Account identity information is unavailable.");
12
+ if (response.status === 404)
13
+ throw connectError("unsupported_operation", "This Connect server does not support people discovery.");
14
+ if (response.status === 401)
15
+ throw connectError("authorization_expired", "The application authorization is no longer active.");
16
+ if (response.status === 403)
17
+ throw connectError("access_denied", "This application was not approved to read this identity information.");
18
+ if (!response.ok)
19
+ throw connectError("temporarily_unavailable", "Account identity information is unavailable.");
20
+ return decodeJsonResponse(response, "invalid_operation_response", "Connect returned invalid identity information.");
21
+ });
22
+ }
23
+ /** Account metadata and the collection's person records, resolved one way for every app. */
24
+ export class MdbasePeopleClient {
25
+ context;
26
+ constructor(context) {
27
+ this.context = context;
28
+ }
29
+ current(options) {
30
+ return captureConnectOutcome(() => this.readCurrent(options), COMMON_OPERATION_PROBLEM_CODES);
31
+ }
32
+ members(options) {
33
+ return captureConnectOutcome(() => this.readMembers(options), COMMON_OPERATION_PROBLEM_CODES);
34
+ }
35
+ /**
36
+ * Reads every record of every type implementing `mdbase.person` 2.0.0, across
37
+ * all pages, and resolves the current account. Any failed read fails the
38
+ * whole directory: a partial directory is never presented as complete.
39
+ */
40
+ directory(options = {}) {
41
+ return captureConnectOutcome(async () => {
42
+ const { members: memberMode = "if-approved", ...request } = options;
43
+ const [account, members, records] = await Promise.all([
44
+ this.readCurrent(request),
45
+ memberMode === "omit" ? undefined : this.readMembers(request).catch((error) => {
46
+ if (memberMode === "if-approved" && error instanceof MdbaseConnectError && error.code === "access_denied")
47
+ return undefined;
48
+ throw error;
49
+ }),
50
+ this.readRecords(request)
51
+ ]);
52
+ return {
53
+ account,
54
+ me: resolvePerson(records, account),
55
+ people: records.people,
56
+ invalid: records.invalid.map(({ path, reason }) => ({ path, reason })),
57
+ ...(members ? { members } : {})
58
+ };
59
+ }, COLLECTION_QUERY_PROBLEM_CODES);
60
+ }
61
+ async readCurrent(options) {
62
+ const value = await this.context.request("identity", options);
63
+ const account = profile(value);
64
+ const settings = value.person_settings_url;
65
+ if (settings === undefined)
66
+ return account;
67
+ if (typeof settings !== "string" || !isHttpUrl(settings))
68
+ throw invalidResponse();
69
+ return { ...account, personSettingsUrl: settings };
70
+ }
71
+ async readMembers(options) {
72
+ const body = await this.context.request("members", options);
73
+ if (!body || typeof body !== "object" || !("members" in body) || !Array.isArray(body.members))
74
+ throw invalidResponse();
75
+ return body.members.map((member) => {
76
+ const account = profile(member);
77
+ if (!member || typeof member !== "object" || !("role" in member)
78
+ || (member.role !== "owner" && member.role !== "editor" && member.role !== "viewer"))
79
+ throw invalidResponse();
80
+ return { ...account, role: member.role };
81
+ });
82
+ }
83
+ async readRecords(options) {
84
+ const description = unwrap(await this.context.describe(options));
85
+ const contract = description.contracts.find((candidate) => candidate.id === PERSON_CONTRACT.id && candidate.version === PERSON_CONTRACT.version);
86
+ const projections = new Map();
87
+ for (const implementation of contract?.implementations ?? []) {
88
+ for await (const outcome of this.context.queryPages({ contract: { ...PERSON_CONTRACT, type: implementation.typeName } }, { signal: options.signal, pageSize: 200 })) {
89
+ for (const record of unwrap(outcome).results) {
90
+ const entries = projections.get(record.path) ?? [];
91
+ entries.push({ typeName: implementation.typeName, value: record.frontmatter ?? {} });
92
+ projections.set(record.path, entries);
93
+ }
94
+ }
95
+ }
96
+ const people = [];
97
+ const invalid = [];
98
+ for (const [path, entries] of projections) {
99
+ const parsed = entries.map((entry) => parsePerson(entry.value));
100
+ const first = parsed[0];
101
+ const reason = parsed.find((result) => "reason" in result);
102
+ if (reason && "reason" in reason) {
103
+ invalid.push({ path, reason: reason.reason, claims: entries.flatMap((entry) => looseIdentities(entry.value)) });
104
+ }
105
+ else if (parsed.some((result) => JSON.stringify(result) !== JSON.stringify(first))) {
106
+ invalid.push({ path, reason: "Its Person implementations project different values.", claims: entries.flatMap((entry) => looseIdentities(entry.value)) });
107
+ }
108
+ else if (first && !("reason" in first)) {
109
+ people.push({ path, ...first, typeNames: entries.map((entry) => entry.typeName).sort() });
110
+ }
111
+ }
112
+ const byPath = (left, right) => left.path.localeCompare(right.path);
113
+ return { people: people.sort(byPath), invalid: invalid.sort(byPath) };
114
+ }
115
+ }
116
+ function resolvePerson(records, account) {
117
+ const invalid = records.invalid.filter((record) => record.claims.some((claim) => sameIdentity(claim, account)));
118
+ if (invalid.length)
119
+ return { status: "invalid", paths: invalid.map((record) => record.path) };
120
+ const matches = records.people.filter((person) => person.identities.some((identity) => sameIdentity(identity, account)));
121
+ if (matches.length === 0)
122
+ return { status: "unlinked" };
123
+ if (matches.length > 1)
124
+ return { status: "ambiguous", paths: matches.map((person) => person.path) };
125
+ return { status: "linked", person: matches[0] };
126
+ }
127
+ export function sameIdentity(left, right) {
128
+ return left.issuer === right.issuer && left.subject === right.subject;
129
+ }
130
+ function parsePerson(value) {
131
+ const { name, identities = [] } = value;
132
+ if (typeof name !== "string" || !name.trim())
133
+ return { reason: "It needs a non-blank name." };
134
+ if (!Array.isArray(identities))
135
+ return { reason: "Its account identities must be a list." };
136
+ const parsed = [];
137
+ for (const identity of identities) {
138
+ if (!identity || typeof identity !== "object" || Array.isArray(identity)
139
+ || typeof identity.issuer !== "string" || typeof identity.subject !== "string" || !identity.subject.trim()) {
140
+ return { reason: "Each account identity needs an exact issuer and subject." };
141
+ }
142
+ parsed.push({ issuer: identity.issuer, subject: identity.subject });
143
+ }
144
+ return { name, identities: parsed };
145
+ }
146
+ /** Identities an invalid record still appears to claim, so it can block resolution. */
147
+ function looseIdentities(value) {
148
+ const identities = Array.isArray(value.identities) ? value.identities : [];
149
+ return identities.flatMap((identity) => identity && typeof identity === "object" && !Array.isArray(identity)
150
+ && "issuer" in identity && "subject" in identity
151
+ && typeof identity.issuer === "string" && typeof identity.subject === "string"
152
+ ? [{ issuer: identity.issuer, subject: identity.subject }] : []);
153
+ }
154
+ function unwrap(outcome) {
155
+ if (!outcome.ok)
156
+ throw new MdbaseConnectError(outcome.problem);
157
+ return outcome.value;
158
+ }
159
+ function profile(value) {
160
+ if (!value || typeof value !== "object" || !("issuer" in value) || !("subject" in value) || !("name" in value)
161
+ || typeof value.issuer !== "string" || typeof value.subject !== "string" || typeof value.name !== "string"
162
+ || !value.subject.trim() || !value.name.trim() || !isHttpUrl(value.issuer))
163
+ throw invalidResponse();
164
+ return { issuer: value.issuer, subject: value.subject, name: value.name };
165
+ }
166
+ function isHttpUrl(value) {
167
+ try {
168
+ const url = new URL(value);
169
+ return url.protocol === "https:" || url.protocol === "http:";
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
175
+ function invalidResponse() {
176
+ return connectError("invalid_operation_response", "Connect returned invalid identity information.");
177
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdbase-dev/connect",
3
- "version": "0.1.0-beta.112",
3
+ "version": "0.1.0-beta.114",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "@mdbase-dev/connect-protocol": "0.1.0-beta.112"
36
+ "@mdbase-dev/connect-protocol": "0.1.0-beta.114"
37
37
  },
38
38
  "devDependencies": {
39
39
  "esbuild": "^0.28.1",