@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.
Files changed (111) hide show
  1. package/README.md +127 -69
  2. package/dist/Collection.d.ts +17 -9
  3. package/dist/Collection.d.ts.map +1 -1
  4. package/dist/Collection.js +85 -70
  5. package/dist/Collection.js.map +1 -1
  6. package/dist/Resource.d.ts +11 -4
  7. package/dist/Resource.d.ts.map +1 -1
  8. package/dist/Resource.js +34 -28
  9. package/dist/Resource.js.map +1 -1
  10. package/dist/Space.d.ts +20 -5
  11. package/dist/Space.d.ts.map +1 -1
  12. package/dist/Space.js +69 -65
  13. package/dist/Space.js.map +1 -1
  14. package/dist/WasClient.d.ts +185 -57
  15. package/dist/WasClient.d.ts.map +1 -1
  16. package/dist/WasClient.js +340 -140
  17. package/dist/WasClient.js.map +1 -1
  18. package/dist/codec.d.ts +3 -2
  19. package/dist/codec.d.ts.map +1 -1
  20. package/dist/edv/EdvCodec.js +1 -1
  21. package/dist/edv/EdvCodec.js.map +1 -1
  22. package/dist/edv/WasTransport.d.ts +7 -4
  23. package/dist/edv/WasTransport.d.ts.map +1 -1
  24. package/dist/edv/WasTransport.js +39 -20
  25. package/dist/edv/WasTransport.js.map +1 -1
  26. package/dist/edv/acquire.d.ts +3 -3
  27. package/dist/edv/acquire.d.ts.map +1 -1
  28. package/dist/edv/acquire.js +3 -3
  29. package/dist/edv/acquire.js.map +1 -1
  30. package/dist/edv/cipher.d.ts +1 -1
  31. package/dist/edv/cipher.js +1 -1
  32. package/dist/edv/docCipher.d.ts +6 -5
  33. package/dist/edv/docCipher.d.ts.map +1 -1
  34. package/dist/edv/docCipher.js +6 -5
  35. package/dist/edv/docCipher.js.map +1 -1
  36. package/dist/edv/encryption.d.ts +2 -2
  37. package/dist/edv/encryption.d.ts.map +1 -1
  38. package/dist/edv/encryption.js +8 -6
  39. package/dist/edv/encryption.js.map +1 -1
  40. package/dist/edv/refreshingDocCipher.d.ts +3 -3
  41. package/dist/edv/refreshingDocCipher.d.ts.map +1 -1
  42. package/dist/edv/refreshingDocCipher.js +3 -3
  43. package/dist/edv/refreshingDocCipher.js.map +1 -1
  44. package/dist/edv/transportFactory.d.ts +3 -3
  45. package/dist/edv/transportFactory.d.ts.map +1 -1
  46. package/dist/edv/transportFactory.js +3 -3
  47. package/dist/edv/transportFactory.js.map +1 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/internal/codec.d.ts +7 -7
  53. package/dist/internal/codec.d.ts.map +1 -1
  54. package/dist/internal/codec.js +19 -18
  55. package/dist/internal/codec.js.map +1 -1
  56. package/dist/internal/conditional.d.ts +3 -3
  57. package/dist/internal/conditional.d.ts.map +1 -1
  58. package/dist/internal/conditional.js +5 -5
  59. package/dist/internal/conditional.js.map +1 -1
  60. package/dist/internal/grant.d.ts +15 -26
  61. package/dist/internal/grant.d.ts.map +1 -1
  62. package/dist/internal/grant.js +95 -29
  63. package/dist/internal/grant.js.map +1 -1
  64. package/dist/internal/meta.d.ts +8 -8
  65. package/dist/internal/meta.d.ts.map +1 -1
  66. package/dist/internal/meta.js +13 -10
  67. package/dist/internal/meta.js.map +1 -1
  68. package/dist/internal/paths.d.ts +423 -195
  69. package/dist/internal/paths.d.ts.map +1 -1
  70. package/dist/internal/paths.js +582 -369
  71. package/dist/internal/paths.js.map +1 -1
  72. package/dist/internal/policy.d.ts +23 -23
  73. package/dist/internal/policy.d.ts.map +1 -1
  74. package/dist/internal/policy.js +20 -20
  75. package/dist/internal/policy.js.map +1 -1
  76. package/dist/internal/request.d.ts +17 -21
  77. package/dist/internal/request.d.ts.map +1 -1
  78. package/dist/internal/request.js +10 -21
  79. package/dist/internal/request.js.map +1 -1
  80. package/dist/internal/reserved.d.ts +15 -3
  81. package/dist/internal/reserved.d.ts.map +1 -1
  82. package/dist/internal/reserved.js +37 -8
  83. package/dist/internal/reserved.js.map +1 -1
  84. package/dist/internal/revoke.d.ts +34 -13
  85. package/dist/internal/revoke.d.ts.map +1 -1
  86. package/dist/internal/revoke.js +97 -28
  87. package/dist/internal/revoke.js.map +1 -1
  88. package/dist/internal/service.d.ts +27 -7
  89. package/dist/internal/service.d.ts.map +1 -1
  90. package/dist/internal/service.js +51 -45
  91. package/dist/internal/service.js.map +1 -1
  92. package/dist/internal/write.d.ts +18 -18
  93. package/dist/internal/write.d.ts.map +1 -1
  94. package/dist/internal/write.js +27 -27
  95. package/dist/internal/write.js.map +1 -1
  96. package/dist/paths.d.ts +32 -25
  97. package/dist/paths.d.ts.map +1 -1
  98. package/dist/paths.js +32 -25
  99. package/dist/paths.js.map +1 -1
  100. package/dist/sync/port.d.ts +3 -3
  101. package/dist/sync/port.d.ts.map +1 -1
  102. package/dist/sync/port.js +12 -12
  103. package/dist/sync/port.js.map +1 -1
  104. package/dist/sync/provisioning.d.ts +10 -9
  105. package/dist/sync/provisioning.d.ts.map +1 -1
  106. package/dist/sync/provisioning.js +19 -18
  107. package/dist/sync/provisioning.js.map +1 -1
  108. package/dist/sync/types.d.ts +2 -2
  109. package/dist/types.d.ts +13 -5
  110. package/dist/types.d.ts.map +1 -1
  111. package/package.json +3 -3
package/dist/WasClient.js CHANGED
@@ -1,41 +1,46 @@
1
- import { baseUrlOfSpacesUrl, collectionItemsUrl, parseSpaceTarget, spacesUrl } from './internal/paths.js';
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 { createdId, parseResource, readJsonData } from './internal/content.js';
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 { spaceIdOf, submitRevocation } from './internal/revoke.js';
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's Spaces Repository URL, in canonical trailing-slash form
17
- * (`https://host/spaces/`). Service discovery starts here.
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
- serverUrl;
29
+ baseServerUrl;
20
30
  /**
21
- * The server base URL every Space path hangs off, without a trailing slash:
22
- * the parent of {@link serverUrl} (`https://host`, or `https://host/was` on
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
- baseUrl;
34
+ spaceUrl;
26
35
  zcapClient;
27
36
  encryption;
28
37
  /**
29
38
  * @param options {object}
30
- * @param options.serverUrl {string} the server's Spaces Repository URL
31
- * (`https://host/spaces/`, or `https://host/was/spaces/` on a sub-path
32
- * mount). The base URL for Space paths and zcap `invocationTarget`s is
33
- * its parent. Service discovery starts from this URL rather than from the
34
- * base, which is often a landing page with no `service` link. The value
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
- * from `serverUrl`, and still refuses it with `IncompatibleServerError` if
51
- * it names no version this client speaks or advertises a Spaces Repository
52
- * other than `serverUrl`. `service({ refresh: true })` discards it and
53
- * discovers afresh.
54
- * @throws {ValidationError} when `serverUrl` is not a Spaces Repository URL
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({ serverUrl, zcapClient, encryption, serviceDescription }) {
57
- const baseUrl = baseUrlOfSpacesUrl(serverUrl);
58
- if (baseUrl === null) {
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
- this.serverUrl = spacesUrl({ baseUrl });
64
- this.baseUrl = baseUrl;
65
- this.zcapClient = zcapClient;
66
- this.encryption = encryption;
67
- this.#serviceDescription = serviceDescription;
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.serverUrl {string}
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({ serverUrl, signer, encryption, serviceDescription }) {
131
+ static fromSigner({ baseServerUrl, signer, encryption, serviceDescription }) {
87
132
  return new WasClient({
88
- serverUrl,
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
- const info = this.#serviceDescription === undefined
110
- ? await discoverService({ url: this.serverUrl })
111
- : selectServiceVersion(this.#serviceDescription);
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
- * Discovers the server's service description and the WAS version this
132
- * client speaks with it. Follows the `rel="service"` link on the response to
133
- * an unsigned `HEAD` of `serverUrl` (the Spaces Repository URL), then reads
134
- * the linked document with an unsigned `GET`. No capability is invoked. The
135
- * result is memoized for the life of the client and shared by every handle;
136
- * concurrent callers share one discovery, and a failure is not memoized, so
137
- * the next call retries.
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 server's responses carry no
154
- * `service` link (it predates v0.5), the document is malformed, it lists
155
- * no version this client speaks, or it advertises a Spaces Repository URL
156
- * other than `serverUrl`
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
- * Waits for discovery and refuses a server whose service description has no
167
- * Spaces Repository. Discovery has already checked that an advertised
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
- * @returns {Promise<void>}
171
- * @throws {NotSupportedError} when the server does not implement the
172
- * Spaces Repository
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 #assertSpacesRepository() {
175
- const { spacesUrl, version } = await this.service();
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 "${this.baseUrl}" does not implement the Spaces ` +
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
- // `baseUrl`, `zcapClient` and `encryption` are all constructor-fixed, so
185
- // the context is derived once and reused rather than reallocating the
186
- // object on every handle access.
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 space by id. No I/O.
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 spaceId {string}
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
- space(spaceId, options = {}) {
321
+ spaceAt(spaceUrl, options = {}) {
219
322
  return new Space({
220
323
  context: this.#context,
221
- spaceId,
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 URL the service description names.
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.#assertSpacesRepository();
416
+ const spacesUrl = await this.#spacesRepositoryUrl('createSpace');
252
417
  const response = await send(this.#context, {
253
- url: this.serverUrl,
418
+ url: spacesUrl,
254
419
  method: 'POST',
255
420
  json: body
256
421
  });
257
- return this.space(createdId(response));
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.#assertSpacesRepository();
456
+ const spacesUrl = await this.#spacesRepositoryUrl('listSpaces');
281
457
  const walk = await signedPageWalk(this.#context, {
282
- firstUrl: this.serverUrl
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 "${this.serverUrl}" answered with no JSON body.`);
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: collectionItemsUrl(collectionUrl),
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
- try {
408
- new URL(target);
409
- }
410
- catch (err) {
411
- throw new ValidationError(`invocationTarget "${target}" is not a valid absolute URL.`, { cause: err });
412
- }
413
- // `parseSpaceTarget` owns the path grammar: it strips `baseUrl`'s path
414
- // first (so a WAS mounted under a sub-path -- `https://host/was/` --
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 a resource on "${this.baseUrl}".`);
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
- return new Resource({
618
+ handle = new Resource({
436
619
  context,
437
- spaceId: parsed.spaceId,
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
- return new Collection({
627
+ else if (parsed.kind === 'collection') {
628
+ handle = new Collection({
446
629
  context,
447
- spaceId: parsed.spaceId,
630
+ spaceUrl,
448
631
  collectionId: parsed.collectionId,
449
632
  capability: zcap,
450
633
  encryption
451
634
  });
452
635
  }
453
- return new Space({
454
- context,
455
- spaceId: parsed.spaceId,
456
- capability: zcap,
457
- encryption
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) and
463
- * `capability` (a parent capability to attenuate) make this a superset of the
464
- * `space`/`collection` grant sugar.
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}. Derives
474
- * the owning space from the capability's `invocationTarget` (which a
475
- * Space-rooted capability always addresses at or beneath) and submits it to
476
- * that space's revocation endpoint. Equivalent to
477
- * `was.space(id).revoke(zcap)`.
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
- const context = this.#context;
488
- return submitRevocation(context, {
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()`. Resolves
495
- * `path` against `baseUrl`, defaults `action` to `method`, uppercases both
496
- * (as {@link grant} does, so `'put'` signs `PUT`), and signs via the wrapped
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