@interop/was-client 0.99.1 → 0.100.1
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/README.md +127 -69
- package/dist/Collection.d.ts +17 -9
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +85 -70
- package/dist/Collection.js.map +1 -1
- package/dist/Resource.d.ts +11 -4
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +34 -28
- package/dist/Resource.js.map +1 -1
- package/dist/Space.d.ts +20 -5
- package/dist/Space.d.ts.map +1 -1
- package/dist/Space.js +69 -65
- package/dist/Space.js.map +1 -1
- package/dist/WasClient.d.ts +185 -57
- package/dist/WasClient.d.ts.map +1 -1
- package/dist/WasClient.js +340 -140
- package/dist/WasClient.js.map +1 -1
- package/dist/codec.d.ts +3 -2
- package/dist/codec.d.ts.map +1 -1
- package/dist/edv/EdvCodec.js +1 -1
- package/dist/edv/EdvCodec.js.map +1 -1
- package/dist/edv/WasTransport.d.ts +7 -4
- package/dist/edv/WasTransport.d.ts.map +1 -1
- package/dist/edv/WasTransport.js +39 -20
- package/dist/edv/WasTransport.js.map +1 -1
- package/dist/edv/acquire.d.ts +3 -3
- package/dist/edv/acquire.d.ts.map +1 -1
- package/dist/edv/acquire.js +3 -3
- package/dist/edv/acquire.js.map +1 -1
- package/dist/edv/cipher.d.ts +1 -1
- package/dist/edv/cipher.js +1 -1
- package/dist/edv/docCipher.d.ts +6 -5
- package/dist/edv/docCipher.d.ts.map +1 -1
- package/dist/edv/docCipher.js +6 -5
- package/dist/edv/docCipher.js.map +1 -1
- package/dist/edv/encryption.d.ts +2 -2
- package/dist/edv/encryption.d.ts.map +1 -1
- package/dist/edv/encryption.js +8 -6
- package/dist/edv/encryption.js.map +1 -1
- package/dist/edv/refreshingDocCipher.d.ts +3 -3
- package/dist/edv/refreshingDocCipher.d.ts.map +1 -1
- package/dist/edv/refreshingDocCipher.js +3 -3
- package/dist/edv/refreshingDocCipher.js.map +1 -1
- package/dist/edv/transportFactory.d.ts +3 -3
- package/dist/edv/transportFactory.d.ts.map +1 -1
- package/dist/edv/transportFactory.js +3 -3
- package/dist/edv/transportFactory.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/codec.d.ts +7 -7
- package/dist/internal/codec.d.ts.map +1 -1
- package/dist/internal/codec.js +19 -18
- package/dist/internal/codec.js.map +1 -1
- package/dist/internal/conditional.d.ts +3 -3
- package/dist/internal/conditional.d.ts.map +1 -1
- package/dist/internal/conditional.js +5 -5
- package/dist/internal/conditional.js.map +1 -1
- package/dist/internal/grant.d.ts +15 -26
- package/dist/internal/grant.d.ts.map +1 -1
- package/dist/internal/grant.js +95 -29
- package/dist/internal/grant.js.map +1 -1
- package/dist/internal/meta.d.ts +8 -8
- package/dist/internal/meta.d.ts.map +1 -1
- package/dist/internal/meta.js +13 -10
- package/dist/internal/meta.js.map +1 -1
- package/dist/internal/paths.d.ts +423 -195
- package/dist/internal/paths.d.ts.map +1 -1
- package/dist/internal/paths.js +582 -369
- package/dist/internal/paths.js.map +1 -1
- package/dist/internal/policy.d.ts +23 -23
- package/dist/internal/policy.d.ts.map +1 -1
- package/dist/internal/policy.js +20 -20
- package/dist/internal/policy.js.map +1 -1
- package/dist/internal/request.d.ts +17 -21
- package/dist/internal/request.d.ts.map +1 -1
- package/dist/internal/request.js +10 -21
- package/dist/internal/request.js.map +1 -1
- package/dist/internal/reserved.d.ts +15 -3
- package/dist/internal/reserved.d.ts.map +1 -1
- package/dist/internal/reserved.js +37 -8
- package/dist/internal/reserved.js.map +1 -1
- package/dist/internal/revoke.d.ts +34 -13
- package/dist/internal/revoke.d.ts.map +1 -1
- package/dist/internal/revoke.js +97 -28
- package/dist/internal/revoke.js.map +1 -1
- package/dist/internal/service.d.ts +27 -7
- package/dist/internal/service.d.ts.map +1 -1
- package/dist/internal/service.js +51 -45
- package/dist/internal/service.js.map +1 -1
- package/dist/internal/write.d.ts +18 -18
- package/dist/internal/write.d.ts.map +1 -1
- package/dist/internal/write.js +27 -27
- package/dist/internal/write.js.map +1 -1
- package/dist/paths.d.ts +32 -25
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +32 -25
- package/dist/paths.js.map +1 -1
- package/dist/sync/port.d.ts +3 -3
- package/dist/sync/port.d.ts.map +1 -1
- package/dist/sync/port.js +12 -12
- package/dist/sync/port.js.map +1 -1
- package/dist/sync/provisioning.d.ts +10 -9
- package/dist/sync/provisioning.d.ts.map +1 -1
- package/dist/sync/provisioning.js +19 -18
- package/dist/sync/provisioning.js.map +1 -1
- package/dist/sync/types.d.ts +2 -2
- package/dist/types.d.ts +13 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/WasClient.js
CHANGED
|
@@ -1,41 +1,46 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isValidTarget } from '@interop/zcap';
|
|
2
|
+
import { canonicalBaseServerUrl, canonicalContainerUrl, canonicalSpaceUrl, parseAbsoluteHttpUrl, parseSpaceTarget, serviceDescriptionUrl, spacesRepositoryUrl, spaceUrlById, splitCollectionUrl } from './internal/paths.js';
|
|
2
3
|
import { send, rawRequest, unsignedRequest } from './internal/request.js';
|
|
3
|
-
import {
|
|
4
|
+
import { parseResource, readJsonData } from './internal/content.js';
|
|
4
5
|
import { buildPageWalk, collectPages, collectWalk, signedPageWalk, walkItems, walkPagesOrEmpty } from './internal/pagination.js';
|
|
5
6
|
import { delegateGrant } from './internal/grant.js';
|
|
6
|
-
import {
|
|
7
|
+
import { spaceUrlOf, submitRevocation } from './internal/revoke.js';
|
|
7
8
|
import { Memo } from './internal/memo.js';
|
|
8
|
-
import { discoverService, selectServiceVersion } from './internal/service.js';
|
|
9
|
+
import { discoverService, readServiceDescription, selectServiceVersion } from './internal/service.js';
|
|
9
10
|
import { IncompatibleServerError, NotSupportedError, ValidationError, WasServerError } from './errors.js';
|
|
10
11
|
import { zcapClientForSigner } from './zcapClient.js';
|
|
11
12
|
import { Space } from './Space.js';
|
|
12
13
|
import { Collection } from './Collection.js';
|
|
13
14
|
import { Resource } from './Resource.js';
|
|
15
|
+
/**
|
|
16
|
+
* The module-private key under which {@link WasClient.forSpace} hands the
|
|
17
|
+
* constructor a Space URL in place of a base server URL. A `unique symbol`
|
|
18
|
+
* keeps that arm off the public constructor: a caller holding a Space URL goes
|
|
19
|
+
* through `forSpace`, which documents what such a client can and cannot do.
|
|
20
|
+
*/
|
|
21
|
+
const fixedSpace = Symbol('WasClient.forSpace');
|
|
14
22
|
export class WasClient {
|
|
15
23
|
/**
|
|
16
|
-
* The server
|
|
17
|
-
* (
|
|
24
|
+
* The base server URL the client was built from, in canonical form
|
|
25
|
+
* (trailing slash supplied): the URL under which the server lays out
|
|
26
|
+
* `service`, `spaces/` and `space/{space_id}/`. `undefined` for a client
|
|
27
|
+
* built by {@link WasClient.forSpace}, which has none.
|
|
18
28
|
*/
|
|
19
|
-
|
|
29
|
+
baseServerUrl;
|
|
20
30
|
/**
|
|
21
|
-
* The
|
|
22
|
-
*
|
|
23
|
-
* a sub-path mount). Zcap `invocationTarget`s are built beneath it.
|
|
31
|
+
* The Space URL a client built by {@link WasClient.forSpace} was opened on,
|
|
32
|
+
* in canonical form. `undefined` for a client built from a base server URL.
|
|
24
33
|
*/
|
|
25
|
-
|
|
34
|
+
spaceUrl;
|
|
26
35
|
zcapClient;
|
|
27
36
|
encryption;
|
|
28
37
|
/**
|
|
29
38
|
* @param options {object}
|
|
30
|
-
* @param options.
|
|
31
|
-
* (
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* is normalized to canonical form; `baseUrlOfSpacesUrl` in `./paths`
|
|
36
|
-
* states what is accepted. The client is built from this URL only: a
|
|
37
|
-
* server with no Spaces Repository (the spec leaves the endpoint
|
|
38
|
-
* optional) has no URL this constructor accepts and is not supported.
|
|
39
|
+
* @param options.baseServerUrl {string} the server's base server URL: an
|
|
40
|
+
* absolute http(s) URL with no query or fragment, under which the server
|
|
41
|
+
* serves `service`, `spaces/` and `space/{space_id}/`. A missing trailing
|
|
42
|
+
* slash is supplied. For a server that is one fixed Space with no base,
|
|
43
|
+
* use {@link WasClient.forSpace} instead
|
|
39
44
|
* @param options.zcapClient {ZcapClient} an ezcap client holding the signer
|
|
40
45
|
* @param [options.encryption] {EncryptionProvider} the keystore that
|
|
41
46
|
* supplies keys for collections declared encrypted (by their `encryption`
|
|
@@ -46,25 +51,65 @@ export class WasClient {
|
|
|
46
51
|
* closed rather than silently downgrading to plaintext.
|
|
47
52
|
* @param [options.serviceDescription] {ServiceDescription} a service
|
|
48
53
|
* description the caller already holds (from a cache, say). The client
|
|
49
|
-
* selects its version from this copy instead of discovering the document
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* discovers afresh.
|
|
54
|
-
* @throws {ValidationError} when `
|
|
54
|
+
* selects its version from this copy instead of discovering the document,
|
|
55
|
+
* and still refuses it with `IncompatibleServerError` if it names no
|
|
56
|
+
* version this client speaks, or (for a client built from a base server
|
|
57
|
+
* URL) if it describes another server (see {@link service}).
|
|
58
|
+
* `service({ refresh: true })` discards it and discovers afresh.
|
|
59
|
+
* @throws {ValidationError} when `baseServerUrl` is not an absolute
|
|
60
|
+
* http(s) URL, or carries a query or fragment
|
|
55
61
|
*/
|
|
56
|
-
constructor(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
throw new ValidationError(`serverUrl "${serverUrl}" is not a Spaces Repository URL. Pass the ` +
|
|
60
|
-
"server's `/spaces/` URL as an absolute URL, for example " +
|
|
61
|
-
'"https://host/spaces/".');
|
|
62
|
+
constructor(options) {
|
|
63
|
+
if (fixedSpace in options) {
|
|
64
|
+
this.spaceUrl = canonicalSpaceUrl(options[fixedSpace]);
|
|
62
65
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.
|
|
67
|
-
this
|
|
66
|
+
else {
|
|
67
|
+
this.baseServerUrl = canonicalBaseServerUrl(options.baseServerUrl);
|
|
68
|
+
}
|
|
69
|
+
this.zcapClient = options.zcapClient;
|
|
70
|
+
this.encryption = options.encryption;
|
|
71
|
+
this.#serviceDescription = options.serviceDescription;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Opens a client on a single fixed Space: a server that is one Space with
|
|
75
|
+
* no Space id and no base server URL (a personal site whose Collections are
|
|
76
|
+
* `https://alice.example/posts/`, `https://alice.example/images/`, ...).
|
|
77
|
+
* The service description is found by following the `service` link on an
|
|
78
|
+
* unsigned `HEAD` of the Space URL. The Space itself is
|
|
79
|
+
* `was.spaceAt(was.spaceUrl)`, and `collectionAt(url)` opens one of its
|
|
80
|
+
* Collections directly.
|
|
81
|
+
*
|
|
82
|
+
* Such a client has no base server URL, so `space(id)`, `createSpace()` and
|
|
83
|
+
* `listSpaces()` throw `NotSupportedError`. Everything Space-scoped
|
|
84
|
+
* (`spaceAt`, `collectionAt`, `fromCapability`, `grant`, `revoke`, the
|
|
85
|
+
* public reads) works as on any client. Whether the server lets a client
|
|
86
|
+
* create Collections is read from its `collection-management` feature
|
|
87
|
+
* token, as for every server.
|
|
88
|
+
*
|
|
89
|
+
* @param options {object}
|
|
90
|
+
* @param options.spaceUrl {string} the Space's absolute URL, which may be
|
|
91
|
+
* an origin root
|
|
92
|
+
* @param [options.zcapClient] {ZcapClient} an ezcap client holding the
|
|
93
|
+
* signer; one of `zcapClient` and `signer` is given
|
|
94
|
+
* @param [options.signer] {ISigner} a signer to build the `ZcapClient`
|
|
95
|
+
* from, as {@link fromSigner} does
|
|
96
|
+
* @param [options.encryption] {EncryptionProvider} see the constructor
|
|
97
|
+
* @param [options.serviceDescription] {ServiceDescription} see the
|
|
98
|
+
* constructor; not checked against the Space URL, which names no server
|
|
99
|
+
* @returns {WasClient}
|
|
100
|
+
* @throws {ValidationError} when `spaceUrl` is not a Space URL, or neither
|
|
101
|
+
* or both of `zcapClient` and `signer` are given
|
|
102
|
+
*/
|
|
103
|
+
static forSpace({ spaceUrl, zcapClient, signer, encryption, serviceDescription }) {
|
|
104
|
+
if ((zcapClient === undefined) === (signer === undefined)) {
|
|
105
|
+
throw new ValidationError('WasClient.forSpace takes exactly one of `zcapClient` and `signer`.');
|
|
106
|
+
}
|
|
107
|
+
return new WasClient({
|
|
108
|
+
[fixedSpace]: spaceUrl,
|
|
109
|
+
zcapClient: zcapClient ?? zcapClientForSigner({ signer: signer }),
|
|
110
|
+
encryption,
|
|
111
|
+
serviceDescription
|
|
112
|
+
});
|
|
68
113
|
}
|
|
69
114
|
/**
|
|
70
115
|
* Convenience constructor that builds the ezcap `ZcapClient` internally from
|
|
@@ -76,16 +121,16 @@ export class WasClient {
|
|
|
76
121
|
* and never needs a JSON-LD document loader to serve the suite's context.
|
|
77
122
|
*
|
|
78
123
|
* @param options {object}
|
|
79
|
-
* @param options.
|
|
124
|
+
* @param options.baseServerUrl {string} see the constructor
|
|
80
125
|
* @param options.signer {ISigner}
|
|
81
126
|
* @param [options.encryption] {EncryptionProvider} see the constructor
|
|
82
127
|
* @param [options.serviceDescription] {ServiceDescription} see the
|
|
83
128
|
* constructor
|
|
84
129
|
* @returns {WasClient}
|
|
85
130
|
*/
|
|
86
|
-
static fromSigner({
|
|
131
|
+
static fromSigner({ baseServerUrl, signer, encryption, serviceDescription }) {
|
|
87
132
|
return new WasClient({
|
|
88
|
-
|
|
133
|
+
baseServerUrl,
|
|
89
134
|
zcapClient: zcapClientForSigner({ signer }),
|
|
90
135
|
encryption,
|
|
91
136
|
serviceDescription
|
|
@@ -106,35 +151,71 @@ export class WasClient {
|
|
|
106
151
|
}
|
|
107
152
|
#serviceDescription;
|
|
108
153
|
#service = new Memo(async () => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// Every Space path is built beneath the parent of `serverUrl`, so a server
|
|
113
|
-
// that advertises its Spaces Repository elsewhere would be addressed at
|
|
114
|
-
// the wrong mount. Refuse it by name rather than misroute. Both sides are
|
|
115
|
-
// compared in canonical base-URL form, so an advertised value that differs
|
|
116
|
-
// only in serialization (host case, default port, trailing slash) passes.
|
|
117
|
-
if (info.spacesUrl !== undefined) {
|
|
118
|
-
const advertisedBase = baseUrlOfSpacesUrl(info.spacesUrl);
|
|
119
|
-
if (advertisedBase !== this.baseUrl) {
|
|
120
|
-
const why = advertisedBase === null
|
|
121
|
-
? `which is not a Spaces Repository URL (an absolute http(s) ` +
|
|
122
|
-
`URL ending in a "spaces" segment, with no query or fragment).`
|
|
123
|
-
: `but this client was given "${this.serverUrl}".`;
|
|
124
|
-
throw new IncompatibleServerError(`The server advertises its Spaces Repository at ` +
|
|
125
|
-
`"${info.spacesUrl}", ${why}`, { requestUrl: this.serverUrl });
|
|
126
|
-
}
|
|
154
|
+
let info;
|
|
155
|
+
if (this.#serviceDescription !== undefined) {
|
|
156
|
+
info = selectServiceVersion(this.#serviceDescription);
|
|
127
157
|
}
|
|
158
|
+
else if (this.baseServerUrl !== undefined) {
|
|
159
|
+
info = await readServiceDescription({
|
|
160
|
+
serviceUrl: serviceDescriptionUrl({
|
|
161
|
+
baseServerUrl: this.baseServerUrl
|
|
162
|
+
})
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
info = await discoverService({ url: this.spaceUrl });
|
|
167
|
+
}
|
|
168
|
+
this.#assertDescribesThisServer(info);
|
|
128
169
|
return info;
|
|
129
170
|
});
|
|
130
171
|
/**
|
|
131
|
-
*
|
|
132
|
-
* client
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
172
|
+
* Refuses a service description that does not describe the server at this
|
|
173
|
+
* client's base server URL: its `url` must be `{base}service` and its
|
|
174
|
+
* `spaces` member, when present, `{base}spaces/`. A caller-supplied copy
|
|
175
|
+
* keyed wrongly or kept across deployments, or a base that names a
|
|
176
|
+
* different mount than the server's, would otherwise fail later with a 404
|
|
177
|
+
* deep in a Space; refusing here names both values. A client built by
|
|
178
|
+
* {@link forSpace} has no base and is not checked.
|
|
179
|
+
*
|
|
180
|
+
* @param info {ServiceInfo}
|
|
181
|
+
* @throws {IncompatibleServerError}
|
|
182
|
+
*/
|
|
183
|
+
#assertDescribesThisServer(info) {
|
|
184
|
+
if (this.baseServerUrl === undefined) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const expectedUrl = serviceDescriptionUrl({
|
|
188
|
+
baseServerUrl: this.baseServerUrl
|
|
189
|
+
});
|
|
190
|
+
const describedUrl = parseAbsoluteHttpUrl(info.description.url)?.toString();
|
|
191
|
+
if (describedUrl !== expectedUrl) {
|
|
192
|
+
throw new IncompatibleServerError(`The service description names itself "${info.description.url}", ` +
|
|
193
|
+
`but the base server URL "${this.baseServerUrl}" places it at ` +
|
|
194
|
+
`"${expectedUrl}".`);
|
|
195
|
+
}
|
|
196
|
+
if (info.spacesUrl === undefined) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const expectedSpaces = spacesRepositoryUrl({
|
|
200
|
+
baseServerUrl: this.baseServerUrl
|
|
201
|
+
});
|
|
202
|
+
// `selectServiceVersion` stores `spacesUrl` in canonical container form.
|
|
203
|
+
if (info.spacesUrl !== expectedSpaces) {
|
|
204
|
+
throw new IncompatibleServerError(`The service description names the Spaces Repository ` +
|
|
205
|
+
`"${info.spacesUrl}", but the base server URL ` +
|
|
206
|
+
`"${this.baseServerUrl}" places it at "${expectedSpaces}".`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Reads the server's service description and selects the WAS version this
|
|
211
|
+
* client speaks with it. A client built from a base server URL reads
|
|
212
|
+
* `{base}service` with an unsigned `GET` and checks that the document
|
|
213
|
+
* describes that base (its `url` and `spaces` members agree with the
|
|
214
|
+
* layout); one built by {@link forSpace} follows the `rel="service"` link on
|
|
215
|
+
* the response to an unsigned `HEAD` of the Space URL. No capability is
|
|
216
|
+
* invoked. The result is memoized for the life of the client and shared by
|
|
217
|
+
* every handle; concurrent callers share one discovery, and a failure is
|
|
218
|
+
* not memoized, so the next call retries.
|
|
138
219
|
*
|
|
139
220
|
* Every signed request waits on this discovery, so a caller rarely needs to
|
|
140
221
|
* call it first. It is the way to read the server-wide affordances:
|
|
@@ -150,10 +231,10 @@ export class WasClient {
|
|
|
150
231
|
* caller about to rely on an affordance the server may have stopped
|
|
151
232
|
* advertising
|
|
152
233
|
* @returns {Promise<ServiceInfo>}
|
|
153
|
-
* @throws {IncompatibleServerError} when the
|
|
154
|
-
* `service` link (
|
|
155
|
-
* no version this client speaks, or it
|
|
156
|
-
*
|
|
234
|
+
* @throws {IncompatibleServerError} when the probed response carries no
|
|
235
|
+
* `service` link (the server predates v0.5), the document is malformed,
|
|
236
|
+
* it lists no version this client speaks, or it does not describe the
|
|
237
|
+
* server at this client's base server URL
|
|
157
238
|
*/
|
|
158
239
|
async service(options = {}) {
|
|
159
240
|
if (options.refresh) {
|
|
@@ -163,31 +244,48 @@ export class WasClient {
|
|
|
163
244
|
return this.#service.get();
|
|
164
245
|
}
|
|
165
246
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* `spaces` URL names `serverUrl`, so callers address `serverUrl` directly.
|
|
247
|
+
* The base server URL, or the `NotSupportedError` a client built by
|
|
248
|
+
* {@link forSpace} throws from every id-addressed operation.
|
|
169
249
|
*
|
|
170
|
-
* @
|
|
171
|
-
* @
|
|
172
|
-
*
|
|
250
|
+
* @param operation {string} the operation's name, for the error
|
|
251
|
+
* @returns {string}
|
|
252
|
+
* @throws {NotSupportedError}
|
|
253
|
+
*/
|
|
254
|
+
#requireBase(operation) {
|
|
255
|
+
if (this.baseServerUrl === undefined) {
|
|
256
|
+
throw new NotSupportedError(`${operation} needs a base server URL, and this client was opened ` +
|
|
257
|
+
`on the single fixed Space "${this.spaceUrl}", which has none.`);
|
|
258
|
+
}
|
|
259
|
+
return this.baseServerUrl;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The Spaces Repository URL the service description names, which the
|
|
263
|
+
* layout check in discovery has already confirmed is `{base}spaces/`
|
|
264
|
+
* (every signed request waits on discovery anyway).
|
|
265
|
+
*
|
|
266
|
+
* @param operation {string} the operation's name, for the error
|
|
267
|
+
* @returns {Promise<string>}
|
|
268
|
+
* @throws {NotSupportedError} when the client has no base server URL, or
|
|
269
|
+
* the server does not implement the Spaces Repository
|
|
173
270
|
*/
|
|
174
|
-
async #
|
|
175
|
-
|
|
271
|
+
async #spacesRepositoryUrl(operation) {
|
|
272
|
+
this.#requireBase(operation);
|
|
273
|
+
const { spacesUrl, version, description } = await this.service();
|
|
176
274
|
if (spacesUrl === undefined) {
|
|
177
|
-
throw new NotSupportedError(`The server at "${
|
|
275
|
+
throw new NotSupportedError(`The server at "${description.url}" does not implement the Spaces ` +
|
|
178
276
|
`Repository: its WAS ${version} service description entry has no ` +
|
|
179
277
|
'`spaces` URL.');
|
|
180
278
|
}
|
|
279
|
+
return spacesUrl;
|
|
181
280
|
}
|
|
182
281
|
#cachedContext;
|
|
183
282
|
get #context() {
|
|
184
|
-
// `
|
|
185
|
-
//
|
|
186
|
-
//
|
|
283
|
+
// `zcapClient` and `encryption` are constructor-fixed, so the context is
|
|
284
|
+
// derived once and reused rather than reallocating the object on every
|
|
285
|
+
// handle access.
|
|
187
286
|
if (this.#cachedContext === undefined) {
|
|
188
287
|
const readControllerDid = () => this.controllerDid;
|
|
189
288
|
this.#cachedContext = {
|
|
190
|
-
baseUrl: this.baseUrl,
|
|
191
289
|
zcapClient: this.zcapClient,
|
|
192
290
|
// Read lazily, so building a handle costs nothing and a
|
|
193
291
|
// delegation-only client (a `ZcapClient` with a delegation signer and
|
|
@@ -204,9 +302,13 @@ export class WasClient {
|
|
|
204
302
|
return this.#cachedContext;
|
|
205
303
|
}
|
|
206
304
|
/**
|
|
207
|
-
* Returns a lazy handle to a
|
|
305
|
+
* Returns a lazy handle to a Space by its absolute URL, as the server
|
|
306
|
+
* named it (the `Location` of the create response, a listing item's `url`,
|
|
307
|
+
* a stored pointer, or the URL a self-locating DID resolves to). No I/O.
|
|
308
|
+
* The URL is canonicalized (see `canonicalSpaceUrl` on the `./paths`
|
|
309
|
+
* entry), and every Collection and Resource URL derives from it.
|
|
208
310
|
*
|
|
209
|
-
* @param
|
|
311
|
+
* @param spaceUrl {string}
|
|
210
312
|
* @param options {object}
|
|
211
313
|
* @param [options.capability] {IZcap}
|
|
212
314
|
* @param [options.encryption] {EncryptionOverride} the default encryption
|
|
@@ -214,20 +316,81 @@ export class WasClient {
|
|
|
214
316
|
* `EncryptionOverride`); `space.collection(id, { encryption })` overrides
|
|
215
317
|
* it per collection
|
|
216
318
|
* @returns {Space}
|
|
319
|
+
* @throws {ValidationError} when `spaceUrl` is not a Space URL
|
|
217
320
|
*/
|
|
218
|
-
|
|
321
|
+
spaceAt(spaceUrl, options = {}) {
|
|
219
322
|
return new Space({
|
|
220
323
|
context: this.#context,
|
|
221
|
-
|
|
324
|
+
spaceUrl,
|
|
222
325
|
capability: options.capability,
|
|
223
326
|
encryption: options.encryption
|
|
224
327
|
});
|
|
225
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Returns a lazy handle to the Space with id `spaceId` on this client's
|
|
331
|
+
* server, at `{base}space/{spaceId}/` as the spec's Server Layout places
|
|
332
|
+
* it. No I/O. The same handle as `spaceAt` opened on that URL; the Space
|
|
333
|
+
* need not exist yet (`space.configure(...)` with `ifNoneMatch` creates it
|
|
334
|
+
* by id).
|
|
335
|
+
*
|
|
336
|
+
* @param spaceId {string}
|
|
337
|
+
* @param options {object}
|
|
338
|
+
* @param [options.capability] {IZcap}
|
|
339
|
+
* @param [options.encryption] {EncryptionOverride} see {@link spaceAt}
|
|
340
|
+
* @returns {Space}
|
|
341
|
+
* @throws {NotSupportedError} on a client built by {@link forSpace}, which
|
|
342
|
+
* has no base server URL
|
|
343
|
+
* @throws {ValidationError} when `spaceId` is empty or a dot segment
|
|
344
|
+
*/
|
|
345
|
+
space(spaceId, options = {}) {
|
|
346
|
+
const baseServerUrl = this.#requireBase('space(id)');
|
|
347
|
+
return this.spaceAt(spaceUrlById({ baseServerUrl, spaceId }), options);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Returns a lazy handle to a Collection by its absolute URL: the parent
|
|
351
|
+
* container is its Space and the last segment its id, so the handle is
|
|
352
|
+
* what `spaceAt(parent).collection(id)` would produce. No I/O. The direct
|
|
353
|
+
* route for a single fixed Space, where a caller typically holds the
|
|
354
|
+
* Collection URLs (`https://alice.example/posts/`) and nothing else.
|
|
355
|
+
*
|
|
356
|
+
* @param collectionUrl {string}
|
|
357
|
+
* @param options {object}
|
|
358
|
+
* @param [options.capability] {IZcap}
|
|
359
|
+
* @param [options.encryption] {EncryptionOverride}
|
|
360
|
+
* @returns {Collection}
|
|
361
|
+
* @throws {ValidationError} when `collectionUrl` is not a Collection URL
|
|
362
|
+
* (no path segment beneath its origin, a query or fragment), its last
|
|
363
|
+
* segment is a reserved Collection id, or it is not in canonical form
|
|
364
|
+
* (so the handle's requests would address a different URL)
|
|
365
|
+
*/
|
|
366
|
+
collectionAt(collectionUrl, options = {}) {
|
|
367
|
+
const { spaceUrl, collectionId } = splitCollectionUrl(collectionUrl);
|
|
368
|
+
const handle = new Collection({
|
|
369
|
+
context: this.#context,
|
|
370
|
+
spaceUrl,
|
|
371
|
+
collectionId,
|
|
372
|
+
capability: options.capability,
|
|
373
|
+
encryption: options.encryption
|
|
374
|
+
});
|
|
375
|
+
// The handle re-encodes the decoded id, so a URL whose last segment is
|
|
376
|
+
// serialized differently (`%21` for `!`, lower-case hex) would build a
|
|
377
|
+
// handle at another URL: one a bound capability does not cover, and
|
|
378
|
+
// whose root capability id the server never minted. Refuse it, by name,
|
|
379
|
+
// as `fromCapability` does for a non-canonical target.
|
|
380
|
+
const expected = canonicalContainerUrl(collectionUrl);
|
|
381
|
+
if (handle.url !== expected) {
|
|
382
|
+
throw new ValidationError(`"${collectionUrl}" is not in canonical form: the handle would ` +
|
|
383
|
+
`address "${handle.url}" instead.`);
|
|
384
|
+
}
|
|
385
|
+
return handle;
|
|
386
|
+
}
|
|
226
387
|
/**
|
|
227
388
|
* Creates a space (server-generated id unless `id` is given). `name` is
|
|
228
389
|
* optional (both in the spec and on the reference server); `controller` must
|
|
229
390
|
* match the wrapped signer's DID (which is the default). The request goes
|
|
230
|
-
* to the Spaces Repository
|
|
391
|
+
* to the Spaces Repository, `{base}spaces/`, and the returned handle is
|
|
392
|
+
* opened on the Space URL the response's `Location` names, as the server
|
|
393
|
+
* states it.
|
|
231
394
|
*
|
|
232
395
|
* @param desc {object}
|
|
233
396
|
* @param [desc.id] {string}
|
|
@@ -238,7 +401,9 @@ export class WasClient {
|
|
|
238
401
|
* 'Space']`). The server defaults it to `['Space']` and treats it as
|
|
239
402
|
* immutable after creation
|
|
240
403
|
* @returns {Promise<Space>}
|
|
241
|
-
* @throws {NotSupportedError} when the server has no Spaces Repository
|
|
404
|
+
* @throws {NotSupportedError} when the server has no Spaces Repository, or
|
|
405
|
+
* the client was built by {@link forSpace}
|
|
406
|
+
* @throws {WasServerError} when the create response carries no `Location`
|
|
242
407
|
*/
|
|
243
408
|
async createSpace(desc = {}) {
|
|
244
409
|
const controller = desc.controller ?? this.controllerDid;
|
|
@@ -248,13 +413,22 @@ export class WasClient {
|
|
|
248
413
|
...(desc.name !== undefined && { name: desc.name }),
|
|
249
414
|
...(desc.type !== undefined && { type: desc.type })
|
|
250
415
|
};
|
|
251
|
-
await this.#
|
|
416
|
+
const spacesUrl = await this.#spacesRepositoryUrl('createSpace');
|
|
252
417
|
const response = await send(this.#context, {
|
|
253
|
-
url:
|
|
418
|
+
url: spacesUrl,
|
|
254
419
|
method: 'POST',
|
|
255
420
|
json: body
|
|
256
421
|
});
|
|
257
|
-
|
|
422
|
+
// Only the `Location` is needed here: the Space id is read off the Space
|
|
423
|
+
// URL by the handle, so the response body's `id` goes unread.
|
|
424
|
+
const location = response?.headers.get('location');
|
|
425
|
+
if (!location) {
|
|
426
|
+
throw new WasServerError(`The Create Space response from "${spacesUrl}" carries no ` +
|
|
427
|
+
'`Location` header, so the new Space cannot be located.');
|
|
428
|
+
}
|
|
429
|
+
// RFC 9110 permits a relative `Location`; resolve it against the request
|
|
430
|
+
// URL so the handle is opened on an absolute Space URL.
|
|
431
|
+
return this.spaceAt(new URL(location, spacesUrl).toString());
|
|
258
432
|
}
|
|
259
433
|
/**
|
|
260
434
|
* Lists the spaces in the repository visible to the wrapped signer, as a
|
|
@@ -263,7 +437,8 @@ export class WasClient {
|
|
|
263
437
|
* authorized for, auxiliary spaces included. Each item carries the space's
|
|
264
438
|
* `type` array, the same value as its Space Metadata object's `type`, so a
|
|
265
439
|
* caller can tell an auxiliary space (one whose `type` names
|
|
266
|
-
* `AuxiliarySpace`) from a user-data one without reading each space
|
|
440
|
+
* `AuxiliarySpace`) from a user-data one without reading each space, and
|
|
441
|
+
* its `url`, the Space URL to open a handle on with {@link spaceAt}.
|
|
267
442
|
* An unauthorized caller is not an error -- the server
|
|
268
443
|
* returns an empty `items` list (the spec's explicit exception to 404
|
|
269
444
|
* masking), so nothing is revealed about which spaces exist.
|
|
@@ -273,13 +448,14 @@ export class WasClient {
|
|
|
273
448
|
* `totalItems` counts the collected items.
|
|
274
449
|
*
|
|
275
450
|
* @returns {Promise<SpaceListing>}
|
|
276
|
-
* @throws {NotSupportedError} when the server has no Spaces Repository
|
|
451
|
+
* @throws {NotSupportedError} when the server has no Spaces Repository, or
|
|
452
|
+
* the client was built by {@link forSpace}
|
|
277
453
|
* @throws {WasServerError} when the listing cannot be read in full
|
|
278
454
|
*/
|
|
279
455
|
async listSpaces() {
|
|
280
|
-
await this.#
|
|
456
|
+
const spacesUrl = await this.#spacesRepositoryUrl('listSpaces');
|
|
281
457
|
const walk = await signedPageWalk(this.#context, {
|
|
282
|
-
firstUrl:
|
|
458
|
+
firstUrl: spacesUrl
|
|
283
459
|
});
|
|
284
460
|
// The first page always carries the listing body (an unauthorized caller
|
|
285
461
|
// still gets an empty `items` list, not an error), so a `null` walk is a
|
|
@@ -287,7 +463,7 @@ export class WasClient {
|
|
|
287
463
|
// body did not parse as JSON. Report it as one, rather than as a
|
|
288
464
|
// `TypeError` from destructuring `null`.
|
|
289
465
|
if (walk === null) {
|
|
290
|
-
throw new WasServerError(`The space listing at "${
|
|
466
|
+
throw new WasServerError(`The space listing at "${spacesUrl}" answered with no JSON body.`);
|
|
291
467
|
}
|
|
292
468
|
return collectPages(walk);
|
|
293
469
|
}
|
|
@@ -321,7 +497,7 @@ export class WasClient {
|
|
|
321
497
|
async #publicListWalk(collectionUrl) {
|
|
322
498
|
return buildPageWalk({
|
|
323
499
|
// The collection listing endpoint is the trailing-slash items URL.
|
|
324
|
-
firstUrl:
|
|
500
|
+
firstUrl: canonicalContainerUrl(collectionUrl),
|
|
325
501
|
fetchPage: async (pageUrl) => {
|
|
326
502
|
const pageResponse = await unsignedRequest({
|
|
327
503
|
url: pageUrl,
|
|
@@ -384,7 +560,15 @@ export class WasClient {
|
|
|
384
560
|
/**
|
|
385
561
|
* Rebuilds an access handle from a received capability, returning a handle at
|
|
386
562
|
* the depth implied by the capability's `invocationTarget` (space /
|
|
387
|
-
* collection / resource), pre-bound with that capability.
|
|
563
|
+
* collection / resource), pre-bound with that capability. The Space is read
|
|
564
|
+
* off the capability's delegation chain, which starts at the Space's root
|
|
565
|
+
* capability (whose id encodes the Space URL), so a capability from any
|
|
566
|
+
* server locates its own Space with no out-of-band base. The root's target
|
|
567
|
+
* is taken to be the Space URL: a Space URL has no shape of its own, so a
|
|
568
|
+
* chain rooted elsewhere (a bare `was.grant({ target })` for a server with
|
|
569
|
+
* no Space hierarchy roots at its target) is read as if that target were
|
|
570
|
+
* the Space, and the server refuses the handle's requests. See `spaceUrlOf`
|
|
571
|
+
* on the `./paths` entry.
|
|
388
572
|
*
|
|
389
573
|
* A resource-scoped capability cannot read the Collection Metadata object, so
|
|
390
574
|
* an encryption-capable client cannot discover whether the collection is
|
|
@@ -401,28 +585,26 @@ export class WasClient {
|
|
|
401
585
|
* @param [options.encryption] {EncryptionOverride} per-handle encryption
|
|
402
586
|
* override for the returned handle (see `EncryptionOverride`)
|
|
403
587
|
* @returns {Space | Collection | Resource}
|
|
588
|
+
* @throws {ValidationError} when the capability's chain starts at no root
|
|
589
|
+
* capability or at one whose target cannot be a Space URL, or its
|
|
590
|
+
* `invocationTarget` is not at or beneath that Space, addresses a
|
|
591
|
+
* reserved sub-resource with no navigational handle, or is not in
|
|
592
|
+
* canonical form (so the handle's requests would fall outside it)
|
|
404
593
|
*/
|
|
405
594
|
fromCapability(zcap, { encryption } = {}) {
|
|
406
595
|
const target = zcap.invocationTarget;
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
// classifies just as a bare-origin deployment does, which `parseSpacePath`
|
|
416
|
-
// alone cannot) and percent-decodes each segment, since the path builders
|
|
417
|
-
// re-encode every id. Classifying the full segment list -- rather than
|
|
418
|
-
// destructuring the first three ids -- keeps a sub-resource target
|
|
419
|
-
// (`/space/s/policy`, `/space/s/c/r/meta`, ...) from being silently
|
|
420
|
-
// truncated to the nearest containment handle, whose derived URLs would
|
|
421
|
-
// mismatch the capability's invocation target.
|
|
422
|
-
const parsed = parseSpaceTarget({ baseUrl: this.baseUrl, target });
|
|
596
|
+
const spaceUrl = spaceUrlOf(zcap);
|
|
597
|
+
// `parseSpaceTarget` owns the grammar beneath a Space: it percent-decodes
|
|
598
|
+
// each segment (the builders re-encode every id) and classifies the full
|
|
599
|
+
// segment list, so a sub-resource target (`{s}policy`, `{s}{c}/{r}/meta`,
|
|
600
|
+
// ...) is refused rather than silently truncated to the nearest
|
|
601
|
+
// containment handle, whose derived URLs would mismatch the capability's
|
|
602
|
+
// invocation target.
|
|
603
|
+
const parsed = parseSpaceTarget({ spaceUrl, target });
|
|
423
604
|
if (parsed === null) {
|
|
424
605
|
throw new ValidationError(`Cannot derive a handle from invocationTarget "${target}": it does ` +
|
|
425
|
-
`not address
|
|
606
|
+
`not address anything at or beneath the Space "${spaceUrl}" the ` +
|
|
607
|
+
'capability is rooted in.');
|
|
426
608
|
}
|
|
427
609
|
if (parsed.kind === 'sub-resource') {
|
|
428
610
|
throw new ValidationError(`invocationTarget "${target}" addresses a reserved ` +
|
|
@@ -431,37 +613,53 @@ export class WasClient {
|
|
|
431
613
|
'`resource.meta()`), or use the `was.request()` escape hatch.');
|
|
432
614
|
}
|
|
433
615
|
const context = this.#context;
|
|
616
|
+
let handle;
|
|
434
617
|
if (parsed.kind === 'resource') {
|
|
435
|
-
|
|
618
|
+
handle = new Resource({
|
|
436
619
|
context,
|
|
437
|
-
|
|
620
|
+
spaceUrl,
|
|
438
621
|
collectionId: parsed.collectionId,
|
|
439
622
|
resourceId: parsed.resourceId,
|
|
440
623
|
capability: zcap,
|
|
441
624
|
encryption
|
|
442
625
|
});
|
|
443
626
|
}
|
|
444
|
-
if (parsed.kind === 'collection') {
|
|
445
|
-
|
|
627
|
+
else if (parsed.kind === 'collection') {
|
|
628
|
+
handle = new Collection({
|
|
446
629
|
context,
|
|
447
|
-
|
|
630
|
+
spaceUrl,
|
|
448
631
|
collectionId: parsed.collectionId,
|
|
449
632
|
capability: zcap,
|
|
450
633
|
encryption
|
|
451
634
|
});
|
|
452
635
|
}
|
|
453
|
-
|
|
454
|
-
context,
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
636
|
+
else {
|
|
637
|
+
handle = new Space({ context, spaceUrl, capability: zcap, encryption });
|
|
638
|
+
}
|
|
639
|
+
// `send` matches every request URL against the capability's target by
|
|
640
|
+
// string prefix, so a target in a non-canonical serialization (upper-case
|
|
641
|
+
// host, explicit default port) would let the handle be built and then
|
|
642
|
+
// refuse every request from it. Refuse it here, by name.
|
|
643
|
+
if (!isValidTarget({
|
|
644
|
+
invocationTarget: handle.url,
|
|
645
|
+
baseInvocationTarget: target,
|
|
646
|
+
allowTargetAttenuation: true
|
|
647
|
+
})) {
|
|
648
|
+
throw new ValidationError(`invocationTarget "${target}" is not in canonical form: the handle ` +
|
|
649
|
+
`would address "${handle.url}", which the capability does not cover.`);
|
|
650
|
+
}
|
|
651
|
+
return handle;
|
|
459
652
|
}
|
|
460
653
|
/**
|
|
461
654
|
* The general delegation primitive. Delegates a capability per `GrantOptions`
|
|
462
|
-
* and returns the signed zcap to hand off out-of-band. `target` (any URL)
|
|
463
|
-
* `
|
|
464
|
-
*
|
|
655
|
+
* and returns the signed zcap to hand off out-of-band. `target` (any URL),
|
|
656
|
+
* `spaceUrl` (the Space to root a revocable grant in) and `capability` (a
|
|
657
|
+
* parent capability to attenuate) make this a superset of the
|
|
658
|
+
* `space`/`collection` grant sugar, which fills the first two from the
|
|
659
|
+
* handle. Without `spaceUrl` or `capability`, the grant is rooted at the
|
|
660
|
+
* target's own root capability: the shape for a server with no Space
|
|
661
|
+
* hierarchy (fixed collections, or resources alone), where there is no
|
|
662
|
+
* Space to root a chain in. Such a grant is not Space-revocable.
|
|
465
663
|
*
|
|
466
664
|
* @param options {GrantOptions}
|
|
467
665
|
* @returns {Promise<IDelegatedZcap>}
|
|
@@ -470,11 +668,12 @@ export class WasClient {
|
|
|
470
668
|
return delegateGrant(this.#context, options);
|
|
471
669
|
}
|
|
472
670
|
/**
|
|
473
|
-
* The general revocation primitive -- the inverse of {@link grant}.
|
|
474
|
-
* the owning
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
* `
|
|
671
|
+
* The general revocation primitive -- the inverse of {@link grant}. Reads
|
|
672
|
+
* the owning Space off the capability's delegation chain (a Space-rooted
|
|
673
|
+
* chain starts at the Space's root capability) and submits it to that
|
|
674
|
+
* Space's revocation endpoint. Equivalent to `was.spaceAt(url).revoke(zcap)`.
|
|
675
|
+
* The chain root's target is taken to be the Space (see `spaceUrlOf` on the
|
|
676
|
+
* `./paths` entry); the server refuses a chain not rooted exactly in it.
|
|
478
677
|
*
|
|
479
678
|
* Revocation is scoped to one space: there is no cross-space or global
|
|
480
679
|
* revocation. See {@link Space.revoke} for who may call it, what it does and
|
|
@@ -482,18 +681,19 @@ export class WasClient {
|
|
|
482
681
|
*
|
|
483
682
|
* @param zcap {IDelegatedZcap} the delegated capability to revoke
|
|
484
683
|
* @returns {Promise<void>}
|
|
684
|
+
* @throws {ValidationError} when the capability's chain starts at no root
|
|
685
|
+
* capability, or at one whose target cannot be a Space URL
|
|
485
686
|
*/
|
|
486
687
|
async revoke(zcap) {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
spaceId: spaceIdOf(context, zcap),
|
|
688
|
+
return submitRevocation(this.#context, {
|
|
689
|
+
spaceUrl: spaceUrlOf(zcap),
|
|
490
690
|
zcap
|
|
491
691
|
});
|
|
492
692
|
}
|
|
493
693
|
/**
|
|
494
|
-
* The signed escape hatch, mirroring ezcap's generic `request()`.
|
|
495
|
-
*
|
|
496
|
-
*
|
|
694
|
+
* The signed escape hatch, mirroring ezcap's generic `request()`. Takes an
|
|
695
|
+
* absolute `url`, defaults `action` to `method`, uppercases both (as
|
|
696
|
+
* {@link grant} does, so `'put'` signs `PUT`), and signs via the wrapped
|
|
497
697
|
* client. Returns the raw `HttpResponse` and throws raw ky/ezcap
|
|
498
698
|
* errors -- it does not apply the null-on-404 or typed-error conveniences.
|
|
499
699
|
* Like every signed request it waits on {@link service} first, and rejects
|