webdaemon 11.4.3 → 11.5.0

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/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /** Common libs */
2
+ export * from './js/Digest.js'
3
+ export * from './js/KeyPair.js'
4
+ export * from './js/Token.js'
5
+
6
+ /** Browser libs */
7
+ export * from './js/Alert.js'
8
+ export * from './js/BrowserApp.js'
9
+ export * from './js/ParentHelper.js'
10
+ export * from './js/QrCode.js'
11
+ export * from './js/Storage.js'
12
+
13
+ /** Backend libs */
14
+ export * from './ts/Assertions.ts'
15
+ export * from './ts/Lifecycle.ts'
16
+ export * from './ts/Requests.ts'
17
+ export * from './ts/Responses.ts'
package/js/Alert.js ADDED
@@ -0,0 +1,33 @@
1
+ import { BrowserApp } from './BrowserApp.js'
2
+
3
+ /**
4
+ * Returns the currently raised launch alert for this app,
5
+ * or null if none.
6
+ *
7
+ * @return { Promise<AlertType | null> } raised launch alert, or null if none.
8
+ * @throws {string} exception if alert catalog cannot be retrieved.
9
+ */
10
+ export async function getLaunchAlert() {
11
+ const app = await BrowserApp.getInstance()
12
+ const origin = app.getPartyOrigin()
13
+ const url = new URL(`${origin}/catalog`)
14
+ url.searchParams.append('alert', '')
15
+ const response = await fetch(url, {
16
+ headers: {
17
+ 'X-Tabserver-Token': this.getTokenBase64(),
18
+ 'Accept': 'application/json'
19
+ }
20
+ })
21
+ const json = await response.json()
22
+ if ('error' in json) {
23
+ throw json.error
24
+ }
25
+
26
+ const alert = json.ok.alert.find(
27
+ alert => (
28
+ alert.type == 'LAUNCH' &&
29
+ alert.sourceUrl == app.getAppUrl()
30
+ )
31
+ )
32
+ return alert || null
33
+ }
@@ -0,0 +1,334 @@
1
+ import { Token } from './Token.js'
2
+
3
+ /**
4
+ * @module
5
+ * Provides convenience support for WebDaemon apps delivered
6
+ * through browsers.
7
+ *
8
+ * Use the singleton pattern, providing app name on first call
9
+ * to getInstance.
10
+ */
11
+
12
+ const PARTY_PARAM = 'party'
13
+
14
+ export class BrowserApp {
15
+ static #instance
16
+
17
+ #appName
18
+ #appUrlKey
19
+ #partyKey
20
+ #tokensKey
21
+
22
+ /**
23
+ * Gets the singleton instance of BrowserApp. On invocations
24
+ * after the first, the app name must be omitted or match
25
+ * the first-used app name.
26
+ *
27
+ * @param {string=} appName
28
+ * @return {Promise<BrowserApp>} instance of browser app.
29
+ */
30
+ static async getInstance(appName = null) {
31
+ if (
32
+ !appName &&
33
+ !BrowserApp.#instance
34
+ ) {
35
+ throw `Must provide app name`
36
+ }
37
+
38
+ if (
39
+ appName &&
40
+ BrowserApp.#instance &&
41
+ appName !== BrowserApp.#instance.#appName
42
+ ) {
43
+ throw `Cannot change app name to ${appName}`
44
+ }
45
+
46
+ if (!BrowserApp.#instance) {
47
+ BrowserApp.#instance = new BrowserApp(appName)
48
+ await BrowserApp.#instance.init()
49
+ }
50
+
51
+ return BrowserApp.#instance
52
+ }
53
+
54
+ /**
55
+ * Constructs a BrowserApp instance with the app name. This should be
56
+ * unique per origin. Spaces are _NOT_ allowed in this name.
57
+ *
58
+ * @param {string} appName a unique name for this app per origin.
59
+ */
60
+ constructor(appName) {
61
+ this.#appName = appName
62
+ this.#partyKey = `${appName}Party`
63
+ this.#tokensKey = `${appName}Tokens`
64
+ this.#appUrlKey = `${appName}Url`
65
+ }
66
+
67
+ /**
68
+ * Initialises the instance with the party name and tokens passed
69
+ * in the #fragment and ?query string search parameters.
70
+ *
71
+ * - party is passed in the special parameter 'party'.
72
+ * - tokens are passed in the remaining parameters keyed by host name.
73
+ *
74
+ * The entire #fragment is parsed into search params, if present.
75
+ * Query params whose name starts with the special character 'æ' are
76
+ * extracted, if present.
77
+ *
78
+ * The window location is replaced with the 'clean' version that
79
+ * no longer includes tokens.
80
+ *
81
+ * @return {Promise<BrowserApp>} instance promise.
82
+ * @throws {string} error if party token `src` does not match our window location.
83
+ */
84
+ async init() {
85
+ const ourUrl = new URL(globalThis.location)
86
+
87
+ // Extract and clear the fragment component before storing the url.
88
+ const tokenParams = new URLSearchParams(ourUrl.hash.substring(1))
89
+ ourUrl.hash = ''
90
+
91
+ // Extract matching query parameters, add them to the token params
92
+ // and clear them from the url.
93
+ for (const [key, value] of ourUrl.searchParams) {
94
+ if (key.startsWith('æ')) {
95
+ tokenParams.set(key.substring(1), value)
96
+ ourUrl.searchParams.delete(key)
97
+ }
98
+ }
99
+
100
+ sessionStorage[this.#appUrlKey] = ourUrl.toString()
101
+
102
+ if (tokenParams.has(PARTY_PARAM)) {
103
+ sessionStorage[this.#tokensKey] = tokenParams.toString()
104
+ const identityToken = this.getToken()
105
+ await identityToken.verifySignatory()
106
+ sessionStorage[this.#partyKey] = identityToken.getParty()
107
+
108
+ const srcUrl = identityToken.getSourceUrl()
109
+ if (srcUrl.origin !== ourUrl.origin || srcUrl.pathname !== ourUrl.pathname) {
110
+ throw `Party token is for different app: ${srcUrl}`
111
+ }
112
+ }
113
+
114
+ globalThis.history.replaceState(null, '', ourUrl.toString())
115
+ return this
116
+ }
117
+
118
+ /**
119
+ * Returns true if this app is an orphan, i.e. has not been launched from
120
+ * a Dæmon shell.
121
+ *
122
+ * Otherwise returns false.
123
+ *
124
+ * @return {boolean} true if an orphan without Dæmon, otherwise false.
125
+ */
126
+ isOrphan() {
127
+ return (
128
+ sessionStorage[this.#partyKey] == undefined ||
129
+ sessionStorage[this.#tokensKey] == undefined
130
+ )
131
+ }
132
+
133
+ /**
134
+ * Returns the app name. This is normally used to disambiguate properties
135
+ * of this app from others, such as those sharing a web origin and therefore
136
+ * sharing localStorage or sessionStorage.
137
+ *
138
+ * Note that this is a client-side name not known to the tabserver.
139
+ *
140
+ * @return {string} the app name.
141
+ */
142
+ getAppName() {
143
+ return this.#appName
144
+ }
145
+
146
+ /**
147
+ * Returns the app url, obtained from window.location at time of app init,
148
+ * or the empty string if not initialised.
149
+ *
150
+ * @return {string} the app url, or the empty string.
151
+ */
152
+ getAppUrl() {
153
+ return sessionStorage[this.#appUrlKey] || ''
154
+ }
155
+
156
+ /**
157
+ * Returns the party hostname, being the Dæmon that launched this app.
158
+ *
159
+ * @return {string} the party (Dæmon) hostname.
160
+ */
161
+ getParty() {
162
+ return sessionStorage[this.#partyKey]
163
+ }
164
+
165
+ /**
166
+ * Returns the party origin, being the protocol (http: or https:)
167
+ * and the party hostname.
168
+ *
169
+ * @return {string} the party origin.
170
+ */
171
+ getPartyOrigin() {
172
+ return `${globalThis.location.protocol}//${this.getParty()}`
173
+ }
174
+
175
+ /**
176
+ * Returns the base64-encoded token for the supplied party. If no
177
+ * party is specified, returns the token for the launching party.
178
+ *
179
+ * @param {string} party the party, or the launching party if not specified.
180
+ * @return {string} the base64-encoded token for the party.
181
+ */
182
+ getTokenBase64(party = PARTY_PARAM) {
183
+ const tokens = sessionStorage[this.#tokensKey]
184
+ const searchParams = new URLSearchParams(tokens)
185
+ const tokenBase64 = searchParams.get(party)
186
+ if (!tokenBase64) {
187
+ throw `No token for ${party}, check 'audience' in YML`
188
+ }
189
+ return tokenBase64
190
+ }
191
+
192
+
193
+ /**
194
+ * Gets the token for a party either as a base64-encoded string, or as a
195
+ * Token object depending on the asBase64 argument.
196
+ *
197
+ * If the party is not passed or is null, the default is the launching party.
198
+ * If the asBase64 is not passed, the default is true.
199
+ *
200
+ * @param {string=} party the audience for the token. Defaults to the identity token 'party'.
201
+ * @return {Token} the pre-generated token for the given party.
202
+ */
203
+ getToken(party = PARTY_PARAM) {
204
+ const tokenBase64 = this.getTokenBase64(party)
205
+ return new Token(tokenBase64)
206
+ }
207
+
208
+ /**
209
+ * Gets the value of the named parameter, or null if no value is supplied.
210
+ *
211
+ * The parameter name is case insensitive.
212
+ *
213
+ * The parameter is specified as:
214
+ * - an attribute on the webdaemon meta tag, or
215
+ * - a search parameter in the query string of the URL
216
+ *
217
+ * where the query string parameter takes priority if present.
218
+ *
219
+ * @param {string} name
220
+ * @return {string | null} parameter value or null if not present.
221
+ */
222
+ getParam(name) {
223
+
224
+ // Return a matching search parameter if present.
225
+ const searchParams = new URL(globalThis.location).searchParams
226
+ for (const [paramName, value] of searchParams) {
227
+ if (name.toLowerCase() == paramName.toLowerCase()) {
228
+ return value
229
+ }
230
+ }
231
+
232
+ // Return meta element attribute if present, or null.
233
+ const metaElement = document.querySelector('link[rel=webdaemon]')
234
+ return metaElement.getAttribute(name)
235
+ }
236
+
237
+ /**
238
+ * Returns true if the named parameter is present, even if with empty
239
+ * or null value.
240
+ *
241
+ * @param {string} name
242
+ * @return {boolean} true if the parameter exists, otherwise false.
243
+ */
244
+ hasParam(name) {
245
+ const searchParams = new URL(globalThis.location).searchParams
246
+ if (searchParams.has(name)) {
247
+ return true
248
+ }
249
+
250
+ const metaElement = document.querySelector('link[rel=webdaemon]')
251
+ return metaElement.hasAttribute(name)
252
+ }
253
+
254
+ /**
255
+ * Returns the currently raised launch alert for this app,
256
+ * or null if none.
257
+ *
258
+ * @return { Promise<AlertType | null> } raised launch alert, or null if none.
259
+ * @throws {string} exception if alert catalog cannot be retrieved.
260
+ */
261
+ async getLaunchAlert() {
262
+ const origin = this.getPartyOrigin()
263
+ const url = new URL(`${origin}/catalog`)
264
+ url.searchParams.append('alert', '')
265
+ const response = await fetch(url, {
266
+ headers: {
267
+ 'X-Tabserver-Token': this.getTokenBase64(),
268
+ 'Accept': 'application/json'
269
+ }
270
+ })
271
+ const json = await response.json()
272
+ if ('error' in json) {
273
+ throw json.error
274
+ }
275
+
276
+ const alert = json.ok.alert.find(
277
+ alert => (
278
+ alert.type == 'LAUNCH' &&
279
+ alert.sourceUrl == this.getAppUrl()
280
+ )
281
+ )
282
+ return alert || null
283
+ }
284
+
285
+ /**
286
+ * Resolves the currently raised launch alert for this app, if any.
287
+ *
288
+ * @throws {string} exception if alert cannot be resolved.
289
+ */
290
+ async resolveLaunchAlert() {
291
+ const origin = this.getPartyOrigin()
292
+ const url = new URL(`${origin}/resolve`)
293
+ const response = await fetch(url, {
294
+ method: 'POST',
295
+ headers: {
296
+ 'X-Tabserver-Token': this.getTokenBase64(),
297
+ 'Content-Type': 'application/json'
298
+ },
299
+ body: JSON.stringify({
300
+ type: 'LAUNCH',
301
+ source: this.getAppUrl()
302
+ })
303
+ })
304
+ const json = await response.json()
305
+ if ('error' in json) {
306
+ throw json.error
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Resolves the currently raised launch alert for this app, if any.
312
+ *
313
+ * @throws {string} exception if alert cannot be resolved.
314
+ */
315
+ async rejectLaunchAlert() {
316
+ const origin = this.getPartyOrigin()
317
+ const url = new URL(`${origin}/reject`)
318
+ const response = await fetch(url, {
319
+ method: 'POST',
320
+ headers: {
321
+ 'X-Tabserver-Token': this.getTokenBase64(),
322
+ 'Content-Type': 'application/json'
323
+ },
324
+ body: JSON.stringify({
325
+ type: 'LAUNCH',
326
+ source: this.getAppUrl()
327
+ })
328
+ })
329
+ const json = await response.json()
330
+ if ('error' in json) {
331
+ throw json.error
332
+ }
333
+ }
334
+ }
package/js/Digest.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Generates a SHA-1 digest of the supplied string, and returns
3
+ * it as a base64 string optionally truncated to the first 'length'
4
+ * characters.
5
+ *
6
+ * @param {string} message to digest.
7
+ * @return {Promise<string>} base64-encoded digest.
8
+ */
9
+ export async function shortSafeDigest(message, length = 0) {
10
+ const msgUint8 = new TextEncoder().encode(message)
11
+ if (!crypto.subtle) {
12
+ throw 'Requires secure origin (localhost or https:)'
13
+ }
14
+ const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8)
15
+ const hashArray = new Uint8Array(hashBuffer)
16
+ const byteString = String.fromCodePoint(...hashArray)
17
+ const base64 = btoa(byteString)
18
+ const base64Safe = base64
19
+ .replaceAll('+','-')
20
+ .replaceAll('/','_')
21
+ .replaceAll('=','')
22
+ return length ? base64Safe.substring(0, length) : base64Safe
23
+ }
24
+
25
+ /**
26
+ * Generates a SHA-1 digest of the supplied string, and returns
27
+ * it as a hex string optionally truncated to the first 'length'
28
+ * characters.
29
+ *
30
+ * @param {string} message to digest.
31
+ * @return {Promise<string>} hex-encoded digest.
32
+ */
33
+ export async function shortHexDigest(message, length = 0) {
34
+ const msgUint8 = new TextEncoder().encode(message)
35
+ if (!crypto.subtle) {
36
+ throw 'Requires secure origin (localhost or https:)'
37
+ }
38
+ const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8)
39
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
40
+ const hashHex = hashArray
41
+ .map((b) => b.toString(16).padStart(2, '0'))
42
+ .join('')
43
+
44
+ return length ? hashHex.substring(0, length) : hashHex
45
+ }
package/js/KeyPair.js ADDED
@@ -0,0 +1,38 @@
1
+ const DEFAULT_ALGORITHM = {
2
+ name: 'RSASSA-PKCS1-v1_5',
3
+ modulusLength: 2048,
4
+ publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
5
+ hash: 'SHA-256'
6
+ }
7
+
8
+ export class KeyPair {
9
+ #algorithm
10
+ #keypair
11
+
12
+ constructor(algorithm = DEFAULT_ALGORITHM) {
13
+ this.#algorithm = algorithm
14
+ if (!crypto.subtle) {
15
+ throw 'Crypto.subtle requires secure environment'
16
+ }
17
+ }
18
+
19
+ async generate() {
20
+ this.#keypair = await crypto.subtle.generateKey(
21
+ this.#algorithm,
22
+ true,
23
+ ['sign', 'verify']
24
+ )
25
+ }
26
+
27
+ async publicJwk() {
28
+ const publicKey = this.#keypair.publicKey
29
+ const publicJwk = await crypto.subtle.exportKey('jwk', publicKey)
30
+ return publicJwk
31
+ }
32
+
33
+ async privateJwk() {
34
+ const privateKey = this.#keypair.privateKey
35
+ const privateJwk = await crypto.subtle.exportKey('jwk', privateKey)
36
+ return privateJwk
37
+ }
38
+ }