@noeldemartin/solid-utils 0.1.1-next.4e968af69bdec1e90b14bf8107fad4fafd43f952 → 0.1.1-next.50cf83f3260cb09d7b2bcb58480a6696f8596d1c
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/noeldemartin-solid-utils.cjs.js +1 -1
- package/dist/noeldemartin-solid-utils.cjs.js.map +1 -1
- package/dist/noeldemartin-solid-utils.d.ts +33 -8
- package/dist/noeldemartin-solid-utils.esm.js +1 -1
- package/dist/noeldemartin-solid-utils.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/helpers/auth.ts +82 -16
- package/src/helpers/interop.ts +63 -27
- package/src/helpers/vocabs.ts +2 -1
- package/src/models/SolidDocument.ts +38 -37
- package/src/models/SolidStore.ts +61 -0
- package/src/models/index.ts +2 -1
package/src/helpers/auth.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { objectWithoutEmpty, silenced, urlParentDirectory, urlRoot, urlRoute } from '@noeldemartin/utils';
|
|
2
2
|
|
|
3
|
+
import SolidStore from '../models/SolidStore';
|
|
4
|
+
import UnauthorizedError from '../errors/UnauthorizedError';
|
|
5
|
+
import type SolidDocument from '../models/SolidDocument';
|
|
6
|
+
|
|
3
7
|
import { fetchSolidDocument } from './io';
|
|
4
8
|
import type { Fetch } from './io';
|
|
5
9
|
|
|
6
10
|
export interface SolidUserProfile {
|
|
7
11
|
webId: string;
|
|
8
12
|
storageUrls: string[];
|
|
13
|
+
cloaked: boolean;
|
|
14
|
+
writableProfileUrl: string | null;
|
|
9
15
|
name?: string;
|
|
10
16
|
avatarUrl?: string;
|
|
11
17
|
oidcIssuerUrl?: string;
|
|
@@ -13,16 +19,72 @@ export interface SolidUserProfile {
|
|
|
13
19
|
privateTypeIndexUrl?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
22
|
+
async function fetchExtendedUserProfile(webIdDocument: SolidDocument, fetch?: Fetch): Promise<{
|
|
23
|
+
store: SolidStore;
|
|
24
|
+
cloaked: boolean;
|
|
25
|
+
writableProfileUrl: string | null;
|
|
26
|
+
}> {
|
|
27
|
+
const store = new SolidStore(webIdDocument.getQuads());
|
|
28
|
+
const documents: Record<string, SolidDocument | false | null> = { [webIdDocument.url]: webIdDocument };
|
|
29
|
+
const addReferencedDocumentUrls = (document: SolidDocument) => document
|
|
30
|
+
.statements(undefined, 'foaf:isPrimaryTopicOf')
|
|
31
|
+
.map(quad => quad.object.value)
|
|
32
|
+
.forEach(profileDocumentUrl => documents[profileDocumentUrl] = documents[profileDocumentUrl] ?? null);
|
|
33
|
+
const loadProfileDocuments = async (): Promise<void> => {
|
|
34
|
+
for (const [url, document] of Object.entries(documents)) {
|
|
35
|
+
if (document !== null) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const document = await fetchSolidDocument(url, fetch);
|
|
41
|
+
|
|
42
|
+
documents[url] = document;
|
|
43
|
+
store.addQuads(document.getQuads());
|
|
44
|
+
|
|
45
|
+
addReferencedDocumentUrls(document);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof UnauthorizedError) {
|
|
48
|
+
documents[url] = false;
|
|
49
|
+
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
addReferencedDocumentUrls(webIdDocument);
|
|
59
|
+
|
|
60
|
+
do {
|
|
61
|
+
await loadProfileDocuments();
|
|
62
|
+
} while (Object.values(documents).some(document => document === null));
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
store,
|
|
66
|
+
cloaked: Object.values(documents).some(document => document === false),
|
|
67
|
+
writableProfileUrl:
|
|
68
|
+
webIdDocument.isUserWritable()
|
|
69
|
+
? webIdDocument.url
|
|
70
|
+
: Object
|
|
71
|
+
.values(documents)
|
|
72
|
+
.find((document): document is SolidDocument => !!document && document.isUserWritable())
|
|
73
|
+
?.url ?? null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
16
77
|
async function fetchUserProfile(webId: string, fetch?: Fetch): Promise<SolidUserProfile> {
|
|
17
78
|
const documentUrl = urlRoute(webId);
|
|
18
79
|
const document = await fetchSolidDocument(documentUrl, fetch);
|
|
19
80
|
|
|
20
|
-
if (!document.isPersonalProfile())
|
|
21
|
-
throw new Error(
|
|
81
|
+
if (!document.isPersonalProfile() && !document.contains(webId, 'solid:oidcIssuer'))
|
|
82
|
+
throw new Error(`${webId} is not a valid webId.`);
|
|
22
83
|
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
84
|
+
const { store, writableProfileUrl, cloaked } = await fetchExtendedUserProfile(document, fetch);
|
|
85
|
+
const storageUrls = store.statements(webId, 'pim:storage').map(storage => storage.object.value);
|
|
86
|
+
const publicTypeIndex = store.statement(webId, 'solid:publicTypeIndex');
|
|
87
|
+
const privateTypeIndex = store.statement(webId, 'solid:privateTypeIndex');
|
|
26
88
|
|
|
27
89
|
let parentUrl = urlParentDirectory(documentUrl);
|
|
28
90
|
while (parentUrl && storageUrls.length === 0) {
|
|
@@ -37,19 +99,23 @@ async function fetchUserProfile(webId: string, fetch?: Fetch): Promise<SolidUser
|
|
|
37
99
|
parentUrl = urlParentDirectory(parentUrl);
|
|
38
100
|
}
|
|
39
101
|
|
|
40
|
-
return
|
|
102
|
+
return {
|
|
41
103
|
webId,
|
|
42
104
|
storageUrls,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
105
|
+
cloaked,
|
|
106
|
+
writableProfileUrl,
|
|
107
|
+
...objectWithoutEmpty({
|
|
108
|
+
name:
|
|
109
|
+
store.statement(webId, 'vcard:fn')?.object.value ??
|
|
110
|
+
store.statement(webId, 'foaf:name')?.object.value,
|
|
111
|
+
avatarUrl:
|
|
112
|
+
store.statement(webId, 'vcard:hasPhoto')?.object.value ??
|
|
113
|
+
store.statement(webId, 'foaf:img')?.object.value,
|
|
114
|
+
oidcIssuerUrl: store.statement(webId, 'solid:oidcIssuer')?.object.value,
|
|
115
|
+
publicTypeIndexUrl: publicTypeIndex?.object.value,
|
|
116
|
+
privateTypeIndexUrl: privateTypeIndex?.object.value,
|
|
117
|
+
}),
|
|
118
|
+
};
|
|
53
119
|
}
|
|
54
120
|
|
|
55
121
|
export async function fetchLoginUserProfile(loginUrl: string, fetch?: Fetch): Promise<SolidUserProfile | null> {
|
package/src/helpers/interop.ts
CHANGED
|
@@ -1,59 +1,95 @@
|
|
|
1
1
|
import { uuid } from '@noeldemartin/utils';
|
|
2
2
|
|
|
3
3
|
import { createSolidDocument, fetchSolidDocument, solidDocumentExists, updateSolidDocument } from '@/helpers/io';
|
|
4
|
-
import type SolidThing from '@/models/SolidThing';
|
|
5
4
|
import type { Fetch } from '@/helpers/io';
|
|
6
5
|
import type { SolidUserProfile } from '@/helpers/auth';
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
type TypeIndexType = 'public' | 'private';
|
|
8
|
+
|
|
9
|
+
async function mintTypeIndexUrl(user: SolidUserProfile, type: TypeIndexType, fetch?: Fetch): Promise<string> {
|
|
9
10
|
fetch = fetch ?? window.fetch.bind(fetch);
|
|
10
11
|
|
|
11
12
|
const storageUrl = user.storageUrls[0];
|
|
12
|
-
const typeIndexUrl = `${storageUrl}settings
|
|
13
|
+
const typeIndexUrl = `${storageUrl}settings/${type}TypeIndex`;
|
|
13
14
|
|
|
14
15
|
return await solidDocumentExists(typeIndexUrl, fetch)
|
|
15
|
-
? `${storageUrl}settings
|
|
16
|
+
? `${storageUrl}settings/${type}TypeIndex-${uuid()}`
|
|
16
17
|
: typeIndexUrl;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
async function createTypeIndex(user: SolidUserProfile, type: TypeIndexType, fetch?: Fetch) {
|
|
21
|
+
if (user.writableProfileUrl === null) {
|
|
22
|
+
throw new Error('Can\'t create type index without a writable profile document');
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
fetch = fetch ?? window.fetch.bind(fetch);
|
|
21
26
|
|
|
22
|
-
const typeIndexUrl = await
|
|
23
|
-
const typeIndexBody =
|
|
24
|
-
<> a
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const typeIndexUrl = await mintTypeIndexUrl(user, type, fetch);
|
|
28
|
+
const typeIndexBody = type === 'public'
|
|
29
|
+
? '<> a <http://www.w3.org/ns/solid/terms#TypeIndex> .'
|
|
30
|
+
: `
|
|
31
|
+
<> a
|
|
32
|
+
<http://www.w3.org/ns/solid/terms#TypeIndex>,
|
|
33
|
+
<http://www.w3.org/ns/solid/terms#UnlistedDocument> .
|
|
34
|
+
`;
|
|
28
35
|
const profileUpdateBody = `
|
|
29
36
|
INSERT DATA {
|
|
30
|
-
<${user.webId}> <http://www.w3.org/ns/solid/terms
|
|
37
|
+
<${user.webId}> <http://www.w3.org/ns/solid/terms#${type}TypeIndex> <${typeIndexUrl}> .
|
|
31
38
|
}
|
|
32
39
|
`;
|
|
33
40
|
|
|
34
41
|
await Promise.all([
|
|
35
42
|
createSolidDocument(typeIndexUrl, typeIndexBody, fetch),
|
|
36
|
-
updateSolidDocument(user.
|
|
43
|
+
updateSolidDocument(user.writableProfileUrl, profileUpdateBody, fetch),
|
|
37
44
|
]);
|
|
38
45
|
|
|
46
|
+
if (type === 'public') {
|
|
47
|
+
// TODO Implement updating ACLs for the listing itself to public
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
return typeIndexUrl;
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
|
|
53
|
+
async function findRegistrations(
|
|
43
54
|
typeIndexUrl: string,
|
|
44
|
-
|
|
55
|
+
type: string | string[],
|
|
56
|
+
predicate: string,
|
|
45
57
|
fetch?: Fetch,
|
|
46
|
-
): Promise<
|
|
58
|
+
): Promise<string[]> {
|
|
47
59
|
const typeIndex = await fetchSolidDocument(typeIndexUrl, fetch);
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
const types = Array.isArray(type) ? type : [type];
|
|
61
|
+
|
|
62
|
+
return types.map(
|
|
63
|
+
type => typeIndex
|
|
64
|
+
.statements(undefined, 'rdf:type', 'solid:TypeRegistration')
|
|
65
|
+
.filter(statement => typeIndex.contains(statement.subject.value, 'solid:forClass', type))
|
|
66
|
+
.map(statement => typeIndex.statements(statement.subject.value, predicate))
|
|
67
|
+
.flat()
|
|
68
|
+
.map(statement => statement.object.value)
|
|
69
|
+
.filter(url => !!url),
|
|
70
|
+
).flat();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function createPublicTypeIndex(user: SolidUserProfile, fetch?: Fetch): Promise<string> {
|
|
74
|
+
return createTypeIndex(user, 'public', fetch);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function createPrivateTypeIndex(user: SolidUserProfile, fetch?: Fetch): Promise<string> {
|
|
78
|
+
return createTypeIndex(user, 'private', fetch);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function findContainerRegistrations(
|
|
82
|
+
typeIndexUrl: string,
|
|
83
|
+
type: string | string[],
|
|
84
|
+
fetch?: Fetch,
|
|
85
|
+
): Promise<string[]> {
|
|
86
|
+
return findRegistrations(typeIndexUrl, type, 'solid:instanceContainer', fetch);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function findInstanceRegistrations(
|
|
90
|
+
typeIndexUrl: string,
|
|
91
|
+
type: string | string[],
|
|
92
|
+
fetch?: Fetch,
|
|
93
|
+
): Promise<string[]> {
|
|
94
|
+
return findRegistrations(typeIndexUrl, type, 'solid:instance', fetch);
|
|
59
95
|
}
|
package/src/helpers/vocabs.ts
CHANGED
|
@@ -10,7 +10,8 @@ const knownPrefixes: RDFContext = {
|
|
|
10
10
|
foaf: 'http://xmlns.com/foaf/0.1/',
|
|
11
11
|
pim: 'http://www.w3.org/ns/pim/space#',
|
|
12
12
|
purl: 'http://purl.org/dc/terms/',
|
|
13
|
-
|
|
13
|
+
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
|
|
14
|
+
rdfs: 'http://www.w3.org/2000/01/rdf-schema#',
|
|
14
15
|
schema: 'https://schema.org/',
|
|
15
16
|
solid: 'http://www.w3.org/ns/solid/terms#',
|
|
16
17
|
vcard: 'http://www.w3.org/2006/vcard/ns#',
|
|
@@ -1,30 +1,33 @@
|
|
|
1
|
-
import { parseDate } from '@noeldemartin/utils';
|
|
1
|
+
import { arrayFilter, parseDate, stringMatch } from '@noeldemartin/utils';
|
|
2
2
|
import type { Quad } from 'rdf-js';
|
|
3
3
|
|
|
4
4
|
import { expandIRI } from '@/helpers/vocabs';
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import SolidStore from './SolidStore';
|
|
7
7
|
|
|
8
|
-
export
|
|
8
|
+
export enum SolidDocumentPermission {
|
|
9
|
+
Read = 'read',
|
|
10
|
+
Write = 'write',
|
|
11
|
+
Append = 'append',
|
|
12
|
+
Control = 'control',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default class SolidDocument extends SolidStore {
|
|
9
16
|
|
|
10
17
|
public readonly url: string;
|
|
11
18
|
public readonly headers: Headers;
|
|
12
|
-
private quads: Quad[];
|
|
13
19
|
|
|
14
20
|
public constructor(url: string, quads: Quad[], headers: Headers) {
|
|
21
|
+
super(quads);
|
|
22
|
+
|
|
15
23
|
this.url = url;
|
|
16
|
-
this.quads = quads;
|
|
17
24
|
this.headers = headers;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
|
-
public isEmpty(): boolean {
|
|
21
|
-
return this.statements.length === 0;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
27
|
public isPersonalProfile(): boolean {
|
|
25
28
|
return !!this.statement(
|
|
26
29
|
this.url,
|
|
27
|
-
expandIRI('
|
|
30
|
+
expandIRI('rdf:type'),
|
|
28
31
|
expandIRI('foaf:PersonalProfileDocument'),
|
|
29
32
|
);
|
|
30
33
|
}
|
|
@@ -33,41 +36,27 @@ export default class SolidDocument {
|
|
|
33
36
|
return !!this.headers.get('Link')?.match(/<http:\/\/www\.w3\.org\/ns\/pim\/space#Storage>;[^,]+rel="type"/);
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
public
|
|
37
|
-
return
|
|
38
|
-
?? parseDate(this.statement(this.url, 'purl:modified')?.object.value)
|
|
39
|
-
?? this.getLatestDocumentDate()
|
|
40
|
-
?? null;
|
|
39
|
+
public isUserWritable(): boolean {
|
|
40
|
+
return this.getUserPermissions().includes(SolidDocumentPermission.Write);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
public
|
|
44
|
-
return this.
|
|
45
|
-
statement =>
|
|
46
|
-
(!object || statement.object.value === expandIRI(object, { defaultPrefix: this.url })) &&
|
|
47
|
-
(!subject || statement.subject.value === expandIRI(subject, { defaultPrefix: this.url })) &&
|
|
48
|
-
(!predicate || statement.predicate.value === expandIRI(predicate, { defaultPrefix: this.url })),
|
|
49
|
-
);
|
|
43
|
+
public getUserPermissions(): SolidDocumentPermission[] {
|
|
44
|
+
return this.getPermissionsFromWAC('user');
|
|
50
45
|
}
|
|
51
46
|
|
|
52
|
-
public
|
|
53
|
-
|
|
54
|
-
statement =>
|
|
55
|
-
(!object || statement.object.value === expandIRI(object, { defaultPrefix: this.url })) &&
|
|
56
|
-
(!subject || statement.subject.value === expandIRI(subject, { defaultPrefix: this.url })) &&
|
|
57
|
-
(!predicate || statement.predicate.value === expandIRI(predicate, { defaultPrefix: this.url })),
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
return statement ?? null;
|
|
47
|
+
public getPublicPermissions(): SolidDocumentPermission[] {
|
|
48
|
+
return this.getPermissionsFromWAC('public');
|
|
61
49
|
}
|
|
62
50
|
|
|
63
|
-
public
|
|
64
|
-
return this.
|
|
51
|
+
public getLastModified(): Date | null {
|
|
52
|
+
return parseDate(this.headers.get('last-modified'))
|
|
53
|
+
?? parseDate(this.statement(this.url, 'purl:modified')?.object.value)
|
|
54
|
+
?? this.getLatestDocumentDate()
|
|
55
|
+
?? null;
|
|
65
56
|
}
|
|
66
57
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return new SolidThing(subject, statements);
|
|
58
|
+
protected expandIRI(iri: string): string {
|
|
59
|
+
return expandIRI(iri, { defaultPrefix: this.url });
|
|
71
60
|
}
|
|
72
61
|
|
|
73
62
|
private getLatestDocumentDate(): Date | null {
|
|
@@ -81,4 +70,16 @@ export default class SolidDocument {
|
|
|
81
70
|
return dates.length > 0 ? dates.reduce((a, b) => a > b ? a : b) : null;
|
|
82
71
|
}
|
|
83
72
|
|
|
73
|
+
private getPermissionsFromWAC(type: string): SolidDocumentPermission[] {
|
|
74
|
+
const wacAllow = this.headers.get('WAC-Allow') ?? '';
|
|
75
|
+
const publicModes = stringMatch<2>(wacAllow, new RegExp(`${type}="([^"]+)"`))?.[1] ?? '';
|
|
76
|
+
|
|
77
|
+
return arrayFilter([
|
|
78
|
+
publicModes.includes('read') && SolidDocumentPermission.Read,
|
|
79
|
+
publicModes.includes('write') && SolidDocumentPermission.Write,
|
|
80
|
+
publicModes.includes('append') && SolidDocumentPermission.Append,
|
|
81
|
+
publicModes.includes('control') && SolidDocumentPermission.Control,
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
84
85
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Quad } from 'rdf-js';
|
|
2
|
+
|
|
3
|
+
import { expandIRI } from '@/helpers/vocabs';
|
|
4
|
+
|
|
5
|
+
import SolidThing from './SolidThing';
|
|
6
|
+
|
|
7
|
+
export default class SolidStore {
|
|
8
|
+
|
|
9
|
+
private quads: Quad[];
|
|
10
|
+
|
|
11
|
+
public constructor(quads: Quad[] = []) {
|
|
12
|
+
this.quads = quads;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public isEmpty(): boolean {
|
|
16
|
+
return this.statements.length === 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public getQuads(): Quad[] {
|
|
20
|
+
return this.quads.slice(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public addQuads(quads: Quad[]): void {
|
|
24
|
+
this.quads.push(...quads);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public statements(subject?: string, predicate?: string, object?: string): Quad[] {
|
|
28
|
+
return this.quads.filter(
|
|
29
|
+
statement =>
|
|
30
|
+
(!object || statement.object.value === this.expandIRI(object)) &&
|
|
31
|
+
(!subject || statement.subject.value === this.expandIRI(subject)) &&
|
|
32
|
+
(!predicate || statement.predicate.value === this.expandIRI(predicate)),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public statement(subject?: string, predicate?: string, object?: string): Quad | null {
|
|
37
|
+
const statement = this.quads.find(
|
|
38
|
+
statement =>
|
|
39
|
+
(!object || statement.object.value === this.expandIRI(object)) &&
|
|
40
|
+
(!subject || statement.subject.value === this.expandIRI(subject)) &&
|
|
41
|
+
(!predicate || statement.predicate.value === this.expandIRI(predicate)),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return statement ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public contains(subject: string, predicate?: string, object?: string): boolean {
|
|
48
|
+
return this.statement(subject, predicate, object) !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public getThing(subject: string): SolidThing {
|
|
52
|
+
const statements = this.statements(subject);
|
|
53
|
+
|
|
54
|
+
return new SolidThing(subject, statements);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected expandIRI(iri: string): string {
|
|
58
|
+
return expandIRI(iri);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
}
|
package/src/models/index.ts
CHANGED