webdaemon 0.0.0-241205172853

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.
@@ -0,0 +1,302 @@
1
+ import { Token } from './Token.js'
2
+ import { KeyPair } from './KeyPair.js'
3
+
4
+ /**
5
+ * This helper provides the parent capability for a website to
6
+ * check and activate a daemon, and to create a device offer for
7
+ * subsequent claim by consumer devices or browsers.
8
+ *
9
+ * The helper can operate either browser- or server-side.
10
+ *
11
+ * Usage
12
+ * =====
13
+ *
14
+ * // Create the helper for a child daemon.
15
+ * const helper = new ParentHelper()
16
+ *
17
+ * // User-supplied callback function receives public JWK and returns URL path.
18
+ * const callback = ({role, publicJwk}) => {daemon, source, iss}
19
+ * await helper.init(callback)
20
+ *
21
+ * // Succeeds if the child daemon is activated.
22
+ * await helper.checkActivate()
23
+ *
24
+ * // Returns the claim code and check state of a new device offer.
25
+ * await helper.fetchOffer()
26
+ *
27
+ */
28
+
29
+ /*
30
+ * @typedef {Object} JsonWebKey
31
+ * @typedef {'parent'} Role
32
+ *
33
+ * @typedef {Object} CallbackArg
34
+ * @property {JsonWebKey} publicJwk
35
+ * @property {Role} role
36
+ *
37
+ * @typedef {Object} CallbackReturn
38
+ * @typedef {string} daemon the host name of the daemon.
39
+ * @typedef {string | undefined} source the URL of the source HTML page if request made from browser.
40
+ * @property {URL} signatory URL used in the iss field of the token.
41
+ *
42
+ * @typedef {Object} Signatory the object to be served on the iss URL.
43
+ * @property {string} role which is 'parent'.
44
+ * @property {JsonWebKey} publicJwk
45
+
46
+ * @callback SignatoryCallback
47
+ * @param {Signatory} signatory which is what the user should serve.
48
+ * @returns {Promise<CallbackReturn>} user provides daemon name and public URL of the signatory.
49
+ *
50
+ * @typedef {string} TokenBase64
51
+ *
52
+ * @typedef {Object} OfferOptions
53
+ * @property {'TRANSIENT' | 'PERMANENT'} type the type of the device.
54
+ * @property {number} ttl the time-to-live of device (transient only), in seconds.
55
+ * @property {number} expiry the number of seconds before the offer expires.
56
+ */
57
+
58
+ export class ParentHelper {
59
+ #privateJwk // Signs the token used for activate and offer.
60
+ #publicJwk // Verifies the signed token, must be made publicly visible.
61
+ #daemon // The host name of the daemon being parented.
62
+ #issUrl // Callback-supplied URL for the pubicly visible issuer object.
63
+ #source // The HTML source URL, which must match origin: header iff present in daemon request.
64
+ #token // The token used for check activate and offer calls to the daemon.
65
+ #claimCode // The claim code returned by fetchOffer.
66
+
67
+ /**
68
+ * Generates the keypair for this instance and invokes callback for daemon
69
+ * name and signatory path.
70
+ *
71
+ * The callback should:
72
+ * 1. Save the generated public key such that it is publically visible.
73
+ * 2. Return the externally addressable signatory path.
74
+ *
75
+ * @param {SignatoryCallback} signatoryCallback
76
+ * @return {Promise<TokenBase64>}
77
+ */
78
+ async init(signatoryCallback) {
79
+ await this.#generateKeyPair()
80
+
81
+ const {
82
+ daemon,
83
+ source, // Only needed if offer request is made from a browser.
84
+ issUrl
85
+ } = await signatoryCallback({
86
+ role: 'parent',
87
+ publicJwk: this.#publicJwk
88
+ })
89
+
90
+ this.#daemon = daemon
91
+ this.#source = source
92
+ this.#issUrl = issUrl
93
+
94
+ await this.#buildToken()
95
+ }
96
+
97
+ /**
98
+ * Uses the token to check activate the daemon. An already
99
+ * activated daemon with this parent is retained, or a new
100
+ * daemon is created so long as the sub of the pre-generated
101
+ * token matches a parent record in the provider.
102
+ *
103
+ * @throws {string} exception if daemon cannot be activated.
104
+ */
105
+ async checkActivate() {
106
+ const url = new URL(`${this.#token.getAud()}/activate`)
107
+ const token = await this.#token.asSignedBase64()
108
+ const body = {}
109
+ let response
110
+ try {
111
+ response = await fetch(url, {
112
+ method: 'POST',
113
+ headers: {
114
+ 'x-tabserver-token': token,
115
+ 'content-type': 'application/json'
116
+ },
117
+ body: JSON.stringify(body)
118
+ })
119
+ }
120
+ catch (e) {
121
+ console.error(e)
122
+ throw `Cannot activate ${this.#token.getParty()}`
123
+ }
124
+ const json = await response.json()
125
+ if ('error' in json) {
126
+ throw json.error
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Uses the token to create a device offer on the daemon with default
132
+ * type 'TRANSIENT' and ttl of 300s.
133
+ *
134
+ * @param {'NO_CHECK' | 'DO_CHECK'} flow to use. For QR offers, use checked flow.
135
+ * @param {OfferOptions} options to use when making the offer, if any.
136
+ * @return {Promise<Offer>} offer claimCode and checkState ('NO_CHECK' or 'AWAIT_CHECK').
137
+ */
138
+ async makeOffer(flow, options = {}) {
139
+ const {
140
+ type = 'TRANSIENT',
141
+ ttl = 300,
142
+ expiry = 30
143
+ } = options
144
+
145
+ const url = new URL(`${this.#token.getAud()}/offer`)
146
+ const token = await this.#token.asSignedBase64()
147
+ const body = {
148
+ role: 'party',
149
+ type,
150
+ ttl,
151
+ description: `Offered by ${this.#token.getCounterparty()}`,
152
+ expiry: new Date(Date.now() + 1000 * expiry),
153
+ flow
154
+ }
155
+ const response = await fetch(url, {
156
+ method: 'POST',
157
+ headers: {
158
+ 'x-tabserver-token': token,
159
+ 'content-type': 'application/json'
160
+ },
161
+ body: JSON.stringify(body)
162
+ })
163
+ const json = await response.json()
164
+ if ('error' in json) {
165
+ throw json.error
166
+ }
167
+
168
+ const {
169
+ claimCode,
170
+ _checkState
171
+ } = json.ok
172
+
173
+ this.#claimCode = claimCode
174
+
175
+ return json.ok
176
+ }
177
+
178
+ /**
179
+ * Returns the check state for the offer. This is used when
180
+ * awaiting claim.
181
+ *
182
+ * @return {Promise<void>}
183
+ */
184
+ async checkState() {
185
+ const url = new URL(`${this.#token.getAud()}/offer/check`)
186
+ url.searchParams.append('claimCode', this.#claimCode)
187
+ const token = await this.#token.asSignedBase64()
188
+ const response = await fetch(url, {
189
+ headers: {
190
+ 'x-tabserver-token': token
191
+ }
192
+ })
193
+ const json = await response.json()
194
+ if ('error' in json) {
195
+ throw json.error
196
+ }
197
+
198
+ /**
199
+ * Result has `claimCode` and `checkState`.
200
+ */
201
+ return json.ok.checkState
202
+ }
203
+
204
+ /**
205
+ * Posts the check code that confirms the offering device has
206
+ * got the expected claiming device, and returns the resulting check state.
207
+ *
208
+ * @param {string} checkCode the check code obtained from the claiming device.
209
+ * @return {Promise<ConfirmResult>} the check state following confirmation.
210
+ */
211
+ async confirmClaim(checkCode) {
212
+ const url = new URL(`${this.#token.getAud()}/offer/confirm`)
213
+ const token = await this.#token.asSignedBase64()
214
+ const body = {
215
+ claimCode: this.#claimCode,
216
+ checkCode
217
+ }
218
+ const response = await fetch(url, {
219
+ method: 'POST',
220
+ headers: {
221
+ 'x-tabserver-token': token,
222
+ 'content-type': 'application/json'
223
+ },
224
+ body: JSON.stringify(body)
225
+ })
226
+ const json = await response.json()
227
+ if ('error' in json) {
228
+ throw json.error
229
+ }
230
+ return json.ok
231
+ }
232
+
233
+ /**
234
+ * Returns the daemon name.
235
+ *
236
+ * @return {string} daemon name, e.g. 'daemon.once.id'.
237
+ */
238
+ getDaemon() {
239
+ return this.#daemon
240
+ }
241
+
242
+ /**
243
+ * Returns the daemon origin, suitable for redirection or claiming
244
+ * a code.
245
+ *
246
+ * @return {URL} daemon origin e.g. 'https://daemon.once.id'.
247
+ */
248
+ getDaemonUrl() {
249
+ return new URL(`${this.#issUrl.protocol}//${this.#daemon}`)
250
+ }
251
+
252
+ /**
253
+ * Generates the public and private keypair used for the check
254
+ * activate and offer calls to the daemon.
255
+ *
256
+ * @return {Promise<void>}
257
+ */
258
+ async #generateKeyPair() {
259
+ const keyPair = new KeyPair()
260
+ await keyPair.generate()
261
+ this.#privateJwk = await keyPair.privateJwk()
262
+ this.#publicJwk = await keyPair.publicJwk()
263
+ }
264
+
265
+ /**
266
+ * Builds the token used in the check activate and offer calls toh
267
+ * the daemon.
268
+ *
269
+ * @return {Promise<void>}
270
+ */
271
+ async #buildToken() {
272
+ const iss = this.#issUrl.toString()
273
+ const aud = `${this.#issUrl.protocol}//${this.#daemon}`
274
+ const sub = this.#issUrl.origin
275
+
276
+ // The `src` attribute is origin and pathname only.
277
+ let src
278
+ if (this.#source) {
279
+ const srcUrl = new URL(this.#source)
280
+ src = `${srcUrl.origin}${srcUrl.pathname}`
281
+ }
282
+ else {
283
+ src = 'party:control'
284
+ }
285
+
286
+ const payload = {
287
+ iss,
288
+ aud,
289
+ sub,
290
+ src,
291
+ scope: {
292
+ 'party:control': 'offer'
293
+ },
294
+ iat: Date.now(),
295
+ exp: Date.now() + 1000 * 60 * 3
296
+ }
297
+
298
+ const token = new Token(payload)
299
+ await token.signWith(Promise.resolve(this.#privateJwk))
300
+ this.#token = token
301
+ }
302
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Utility class to generate a QR code img element which is placed
3
+ * under a parent element.
4
+ */
5
+ const BASE_URL = 'https://qrcode.tec-it.com/API/QRCode'
6
+ export const IMG_CLASS = 'qrcode'
7
+
8
+ export class QrCode {
9
+ #img
10
+ #content
11
+
12
+ /**
13
+ * Constructor takes img element and
14
+ * the string to show in the QR code.
15
+ * @param {HTMLImageElement} img the image element to use.
16
+ * @param {string} content the content of the qr code.
17
+ */
18
+ constructor(img, content) {
19
+ this.#img = img
20
+ this.#content = content
21
+ }
22
+
23
+ /**
24
+ * Returns a promise that is resolved when the image loads
25
+ * successfully, or rejected if the image load fails.
26
+ */
27
+ generate() {
28
+ const url = new URL(BASE_URL)
29
+ url.searchParams.append('data', this.#content)
30
+ this.#img.classList.add(IMG_CLASS)
31
+ this.#img.src = url.toString()
32
+ return new Promise((resolve, reject) => {
33
+ this.#img.onload = resolve
34
+ this.#img.onerror = reject
35
+ })
36
+ }
37
+ }
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Readme</title>
6
+ </head>
7
+ <body>
8
+ <h1 id="javascript-library">Javascript library</h1>
9
+ <p>This directory holds Javascript source files designed to be used in
10
+ either the browser or a daemon runner, served from a public site such as
11
+ https://webdaemon.online.</p>
12
+ </body>
13
+ </html>
@@ -0,0 +1,4 @@
1
+ # Javascript library
2
+ This directory holds Javascript source files designed to be
3
+ used in either the browser or a daemon runner, served from
4
+ a public site such as https://webdaemon.online.
Binary file
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Storage keys are 1 or more slash-separated components
3
+ * using the characters a-z, A-Z, 0-9, hyphen and period.
4
+ *
5
+ * Examples:
6
+ * - acmeSetting
7
+ * - acme.com/applicant/22fc01-b0d3084870b-fcae49ac2-892668
8
+ * - address/line1
9
+ * - url/acme.com
10
+ *
11
+ * The key needs to work as part of a REST/HTTP pathname.
12
+ */
13
+ const KEY_PATTERN = /^[\w\-.]+(?:\/[\w\-.]+)*$/
14
+
15
+ /**
16
+ * As above, but % and _ are both allowed as a wildcards.
17
+ */
18
+ const KEYLIKE_PATTERN = /^[\w\-.%_]+(?:\/[\w\-.%_]+)*$/
19
+
20
+ /**
21
+ * @typedef {Object} Ok
22
+ * @property {Plain} ok
23
+ *
24
+ * @typedef {Object} Error
25
+ * @property {string} error
26
+ */
27
+
28
+ /**
29
+ * An instance of this class is normally obtained from BrowserApp, which constructs
30
+ * the instance using the party token.
31
+ */
32
+ export class Storage {
33
+ #token // Party token which must include getitem and setitem capabilities on party:control.
34
+
35
+ constructor(token) {
36
+ this.#token = token
37
+ }
38
+
39
+ getItem(key) {
40
+ return getItem(this.#token, key)
41
+ }
42
+
43
+ getItemsLike(keylike) {
44
+ return getItemsLike(this.#token, keylike)
45
+ }
46
+
47
+ setItem(key, value) {
48
+ return setItem(this.#token, key, value)
49
+ }
50
+
51
+ removeItem(key) {
52
+ return removeItem(this.#token, key)
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Throws an exception if the key being used is not
58
+ * in a valid format.
59
+ */
60
+ function assertValid(key) {
61
+ if (key.match(KEY_PATTERN) == null) {
62
+ throw `DaemonStorage: Invalid key: '${key}`
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Throws an exception if the key like pattern being used
68
+ * is not in a valid format.
69
+ */
70
+ function assertValidLike(keylike) {
71
+ if (keylike.match(KEYLIKE_PATTERN) == null) {
72
+ throw `DaemonStorage: Invalid like key: ${keylike}`
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Returns the value as a JSON string
78
+ *
79
+ * @return {string} JSON string.
80
+ * @throws {string} exception if the value is not JSON.
81
+ */
82
+ function serialise(value) {
83
+ try {
84
+ return JSON.stringify(value)
85
+ }
86
+ catch (_e) {
87
+ throw `DaemonStorage: Invalid value`
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Sets an item in daemon storage.
93
+ *
94
+ * The value must be an object or primitive
95
+ * which is serialised before saving.
96
+ *
97
+ * @param {Token} token to use.
98
+ * @param {string} key the item key.
99
+ * @param {any} value the serialisable value for the item.
100
+ * @return {Promise<Ok | Error>} ok or error object.
101
+ */
102
+ export async function setItem(token, key, value) {
103
+ assertValid(key)
104
+ const body = serialise(value)
105
+ const url = `${token.getAud()}/storage/${key}`
106
+ const response = await fetch(url, {
107
+ method: 'PUT',
108
+ headers: {
109
+ 'X-Tabserver-Token': token.asSignedBase64(),
110
+ 'Content-Type': 'application/json'
111
+ },
112
+ body
113
+ })
114
+ return response.json()
115
+ }
116
+
117
+ /**
118
+ * Gets an item from daemon storage.
119
+ *
120
+ * Returns an 'ok' object with the value if present, otherwise
121
+ * returns an 'error' object.
122
+ *
123
+ * @param {Token} token to use.
124
+ * @param {string} key the item key to retrieve.
125
+ * @return {Promise<Ok | Error>} ok object with value, or error.
126
+ */
127
+ export async function getItem(token, key) {
128
+ assertValid(key)
129
+ const url = `${token.getAud()}/storage/${key}`
130
+ const response = await fetch(url, {
131
+ method: 'GET',
132
+ headers: {
133
+ 'X-Tabserver-Token': token.asSignedBase64()
134
+ }
135
+ })
136
+ return response.json()
137
+ }
138
+
139
+ /**
140
+ * Gets zero or more items from daemon storage, whose
141
+ * keys match the keyLike pattern. This is normally
142
+ * something like 'my/prefix/%' which gets all values
143
+ * with that prefix.
144
+ *
145
+ * You can use both '%' and '_' as wildcards for string
146
+ * of any length and single character respectively.
147
+ *
148
+ * @param {Token} token to use.
149
+ * @param {string} the item keylike pattern to match.
150
+ * @return {Promise<Ok>} ok object with list, or error.
151
+ */
152
+ export async function getItemsLike(token, keylike) {
153
+ assertValidLike(keylike)
154
+ const url = new URL(`${token.getAud()}/storage`)
155
+ url.searchParams.append('like', keylike)
156
+ const response = await fetch(url, {
157
+ method: 'GET',
158
+ headers: {
159
+ 'X-Tabserver-Token': token.asSignedBase64()
160
+ }
161
+ })
162
+ return await response.json()
163
+ }
164
+
165
+ /**
166
+ * Removes an item from daemon storage.
167
+ *
168
+ * @param {Token} token to use.
169
+ * @param {string} key the item key to remove.
170
+ * @return {Promise<Ok | Error>} ok or error object.
171
+ */
172
+ export async function removeItem(token, key) {
173
+ assertValid(key)
174
+ const url =`${token.getAud()}/storage/${key}`
175
+ const response = await fetch(url, {
176
+ method: 'DELETE',
177
+ headers: {
178
+ 'X-Tabserver-Token': token.asSignedBase64()
179
+ }
180
+ })
181
+ return response.json()
182
+ }