@ccmsg/protocol 2.1.1 → 2.2.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/package.json +1 -1
- package/src/attributes.ts +11 -1
- package/src/common/auth.ts +95 -15
- package/src/fixtures/common.ts +2 -2
- package/src/fixtures/ids.ts +11 -0
- package/src/fixtures/topics.ts +32 -2
- package/src/identifiers.ts +75 -6
package/package.json
CHANGED
package/src/attributes.ts
CHANGED
|
@@ -37,7 +37,17 @@ export interface OpAttributes {
|
|
|
37
37
|
* What the carrier decides is not authorization but what the op can do: these
|
|
38
38
|
* are the ops that set or read a cookie, which a frame on an open connection
|
|
39
39
|
* cannot, and they answer before any identity is settled. The route each is
|
|
40
|
-
* published at belongs to the instance, not here.
|
|
40
|
+
* published at belongs to the instance, not here.
|
|
41
|
+
*
|
|
42
|
+
* Being reachable from a page is also what gives the three that settle an
|
|
43
|
+
* identity the only headers this contract reads over HTTP: the `Origin` a
|
|
44
|
+
* browser states, held to the origin of the web UI the credential or the
|
|
45
|
+
* registration names, and `Sec-Fetch-Site`, which has to say the call came
|
|
46
|
+
* from a page at all — a navigation typed into the address bar is not how
|
|
47
|
+
* anyone authenticates. A missing header is a failure like a wrong one, and either
|
|
48
|
+
* answers `auth_invalid` without saying which. `auth.challenge` is checked
|
|
49
|
+
* against neither, having nothing yet to be checked against; what it hands
|
|
50
|
+
* out is spendable only at its issuer. */
|
|
41
51
|
readonly carrier?: "http";
|
|
42
52
|
/** Present when the role changes what the reply may contain rather than
|
|
43
53
|
* whether the call is allowed. */
|
package/src/common/auth.ts
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
import { type Static, Type } from "@sinclair/typebox";
|
|
2
2
|
import { request, response, topicFrame } from "../envelope.ts";
|
|
3
|
-
import { Endpoint, InstanceId, Timestamp } from "../identifiers.ts";
|
|
3
|
+
import { Endpoint, InstanceId, type Origin, Timestamp, WebUi } from "../identifiers.ts";
|
|
4
|
+
|
|
5
|
+
/** The origin of a web UI's URL: what a browser puts in an `Origin` header and
|
|
6
|
+
* in a credential's `clientDataJSON`, which is the URL's scheme and authority
|
|
7
|
+
* and no more.
|
|
8
|
+
*
|
|
9
|
+
* Derived rather than stored, and derived here rather than once per
|
|
10
|
+
* implementation, because every use of it is an exact comparison against a
|
|
11
|
+
* value a browser serialized. The normalization that makes those comparisons
|
|
12
|
+
* hold — a lowercase scheme and host, a port only where it is not the scheme's
|
|
13
|
+
* own, an address literal in its brackets — is the URL parser's, and this is
|
|
14
|
+
* the one place the contract says so. */
|
|
15
|
+
export function originOf(webui: string): Origin {
|
|
16
|
+
return new URL(webui).origin;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The WebAuthn relying party a credential made at a web UI is created under:
|
|
20
|
+
* the host of its URL, port and scheme left off, as a relying party is a domain
|
|
21
|
+
* and not an origin.
|
|
22
|
+
*
|
|
23
|
+
* Derived for the same reason as the origin, and held to the host exactly. A
|
|
24
|
+
* client will accept a relying party that is the page's effective domain or a
|
|
25
|
+
* registrable suffix of it, so anything shorter than the host would be one
|
|
26
|
+
* credential several sites could answer with — which is the single thing
|
|
27
|
+
* binding a credential to one web UI rules out. An assertion's `rpIdHash` is
|
|
28
|
+
* the SHA-256 of what this returns. */
|
|
29
|
+
export function rpIdOf(webui: string): string {
|
|
30
|
+
return new URL(webui).hostname;
|
|
31
|
+
}
|
|
4
32
|
|
|
5
33
|
/** A value that is nothing but bytes to everyone who handles it: a token, a
|
|
6
34
|
* challenge, a credential id, a signature. Spelled base64url without padding so
|
|
@@ -88,9 +116,19 @@ export const RegisterClaims = Type.Object(
|
|
|
88
116
|
* registration is posted to `<endpoint>auth/register`, and the cookie set
|
|
89
117
|
* for it hangs under the same prefix. */
|
|
90
118
|
endpoint: Endpoint,
|
|
91
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
|
|
119
|
+
/** Where the URL sends the person: the web UI they will open it at, and so
|
|
120
|
+
* the page the credential will be made by. It is not read off the endpoint,
|
|
121
|
+
* the UI being publishable anywhere, and a registration URL that did not
|
|
122
|
+
* name it would not be a URL anyone could open.
|
|
123
|
+
*
|
|
124
|
+
* Its origin (`originOf`) is what the ceremony is then held to, and it is
|
|
125
|
+
* also what lets a first registration be answered across sites at all: an
|
|
126
|
+
* instance answers CORS for the origins its credentials name, and the first
|
|
127
|
+
* registration at a new UI has no credential yet — the URL it issued and
|
|
128
|
+
* still holds stands in for one until it does. That is the issuer's own
|
|
129
|
+
* knowledge and travels nowhere, which is why a registration is only
|
|
130
|
+
* completed where it was issued. */
|
|
131
|
+
webui: WebUi,
|
|
94
132
|
expires_at: Timestamp,
|
|
95
133
|
/** Names this registration, so it can be spent once. */
|
|
96
134
|
jti: Type.String({ minLength: 1 }),
|
|
@@ -159,7 +197,13 @@ export type AuthRegisterArgs = Static<typeof AuthRegisterArgs>;
|
|
|
159
197
|
* Only the access token is stated. The refresh token is set as a cookie by the
|
|
160
198
|
* carrier that ran the op, so putting it here too would be a second copy of a
|
|
161
199
|
* secret in a place the browser's script can read — which is the one property
|
|
162
|
-
* the cookie exists to have.
|
|
200
|
+
* the cookie exists to have. That holds however far the page is from the
|
|
201
|
+
* endpoint: a cookie the page's own site cannot reach is sent from a site it
|
|
202
|
+
* does not own only as a partitioned one, which keeps a session taken at one
|
|
203
|
+
* site from being carried to another — the same shape one credential per web UI
|
|
204
|
+
* already has. Sending it at all across sites takes a browser that partitions
|
|
205
|
+
* cookies, which is a premise of this contract rather than a case it
|
|
206
|
+
* accommodates: one that does not is not an environment this is spoken over. */
|
|
163
207
|
export const AuthSession = Type.Object(
|
|
164
208
|
{
|
|
165
209
|
sub: Subject,
|
|
@@ -358,22 +402,43 @@ export const CredentialRecord = Type.Object(
|
|
|
358
402
|
/** The endpoint this credential was registered for, as the registration's
|
|
359
403
|
* claims stated it.
|
|
360
404
|
*
|
|
361
|
-
*
|
|
362
|
-
* accepted only where the
|
|
363
|
-
*
|
|
405
|
+
* Which instance the credential admits its holder to: an assertion is
|
|
406
|
+
* accepted only where the request arrived at this base URL — the same
|
|
407
|
+
* scheme and authority, and a path below it. (The authority the request
|
|
408
|
+
* reached, which is a property of the connection; where the page asking was
|
|
409
|
+
* served from is `webui` below and a separate question.)
|
|
410
|
+
* `https://h.example/` and `https://h.example/personal/` are
|
|
364
411
|
* two endpoints and take two registrations, even on one host and one
|
|
365
412
|
* relying party — the RP ID says which domain an authenticator will answer
|
|
366
413
|
* for, which is a coarser thing than which instance a person has been
|
|
367
414
|
* admitted to. Binding to the base URL rather than the origin is what keeps
|
|
368
415
|
* one instance's credential from being a way into its neighbour. */
|
|
369
416
|
endpoint: Endpoint,
|
|
370
|
-
/** The
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
|
|
417
|
+
/** The web UI the page that created this credential was served from, whose
|
|
418
|
+
* origin is the one it may ever be used from.
|
|
419
|
+
*
|
|
420
|
+
* Holding it to one origin is this contract's rule rather than WebAuthn's. A
|
|
421
|
+
* passkey is bound to its relying party, which may be a suffix of the host,
|
|
422
|
+
* so the authenticator alone would answer for every origin under that
|
|
423
|
+
* suffix. What holds a credential to one is the check made against
|
|
424
|
+
* this: the `clientDataJSON.origin` of every ceremony, registration and
|
|
425
|
+
* assertion alike, has to equal `originOf` this URL. The relying party is
|
|
426
|
+
* `rpIdOf` the same URL, which is what makes the authenticator's own
|
|
427
|
+
* binding say the same thing rather than something wider.
|
|
428
|
+
*
|
|
429
|
+
* The URL is what is kept, and the origin read off it where a header is
|
|
430
|
+
* matched — a token minted here carries the same URL and its connection's
|
|
431
|
+
* `Origin` is held to the origin of it, and the origins of an endpoint's
|
|
432
|
+
* credentials are the set the HTTP auth ops answer CORS for. Keeping the
|
|
433
|
+
* origin alongside instead would be a second copy of one fact, able to
|
|
434
|
+
* disagree with the URL a person is actually sent to. A person using web
|
|
435
|
+
* UIs at two origins holds two credentials, one per origin; two UIs under
|
|
436
|
+
* one origin are one place to every check here, there being no path in an
|
|
437
|
+
* `Origin` header to tell them apart by.
|
|
438
|
+
*
|
|
439
|
+
* Apart from `endpoint` because the two answer different questions: which
|
|
440
|
+
* page may speak, and which instance it may speak to. */
|
|
441
|
+
webui: WebUi,
|
|
377
442
|
/** The authenticator's counter, when it keeps one. Synced passkeys report
|
|
378
443
|
* zero forever, so only a pair of non-zero readings says anything, and a
|
|
379
444
|
* reading below the last one is a refusal. */
|
|
@@ -432,6 +497,21 @@ export const TokenFamily = Type.Object(
|
|
|
432
497
|
sub: Subject,
|
|
433
498
|
/** The instance that minted the family and the only one that may write it. */
|
|
434
499
|
iss: InstanceId,
|
|
500
|
+
/** The web UI the page that authenticated was served from, carried over
|
|
501
|
+
* from the credential that answered.
|
|
502
|
+
*
|
|
503
|
+
* What a connection presenting one of these tokens is held to: the
|
|
504
|
+
* handshake compares `originOf` this with the `Origin` the browser states,
|
|
505
|
+
* and a page from anywhere else is refused however good the token is —
|
|
506
|
+
* refused as an upgrade that does not happen, there being no connection yet
|
|
507
|
+
* to answer an error on. A handshake that states no `Origin` at all is
|
|
508
|
+
* refused the same way: every gate has to be passed, and a caller with
|
|
509
|
+
* nothing to compare has not passed this one. Without it a token that leaked would be usable
|
|
510
|
+
* from any page at all, since it says who the person is and nothing about
|
|
511
|
+
* what is holding it. It lives on the family rather than inside the token's
|
|
512
|
+
* own spelling because every instance has the family and none of them has
|
|
513
|
+
* the minting instance's reading of an opaque value. */
|
|
514
|
+
webui: WebUi,
|
|
435
515
|
access: Type.Object({ value: Base64Url, expires_at: Timestamp }),
|
|
436
516
|
refresh: Type.Object({ value: Base64Url, expires_at: Timestamp }),
|
|
437
517
|
/** When the family was last rotated, and what the client said prompted it.
|
package/src/fixtures/common.ts
CHANGED
|
@@ -36,7 +36,7 @@ import type {
|
|
|
36
36
|
} from "../common/topics.ts";
|
|
37
37
|
import { FIXTURE_IDS, FIXTURE_NOW } from "./ids.ts";
|
|
38
38
|
|
|
39
|
-
const { sid, instance, other_instance, endpoint, other_endpoint, request_id } = FIXTURE_IDS;
|
|
39
|
+
const { sid, instance, other_instance, endpoint, other_endpoint, webui, request_id } = FIXTURE_IDS;
|
|
40
40
|
|
|
41
41
|
export const HELLO_SESSION_REQUEST: Static<typeof HelloSessionRequest> = {
|
|
42
42
|
request_id,
|
|
@@ -277,7 +277,7 @@ export const AUTH_RESOLVE_RESPONSE = {
|
|
|
277
277
|
sub: SUBJECT,
|
|
278
278
|
unit: "personal",
|
|
279
279
|
endpoint,
|
|
280
|
-
|
|
280
|
+
webui,
|
|
281
281
|
expires_at: FIXTURE_NOW + 600_000,
|
|
282
282
|
jti: "01J9Z3W2Q",
|
|
283
283
|
user_id: "dXNlci1oYW5kbGU",
|
package/src/fixtures/ids.ts
CHANGED
|
@@ -9,6 +9,17 @@ export const FIXTURE_IDS = {
|
|
|
9
9
|
other_instance: "a1b2c3d4e5f60718293a4b5c6d7e8f90",
|
|
10
10
|
endpoint: "https://mba.example.ts.net/ccmsg/personal/",
|
|
11
11
|
other_endpoint: "https://nuc.example.ts.net/ccmsg/personal/",
|
|
12
|
+
/** Where the web UI is published, which is nobody's endpoint: a credential is
|
|
13
|
+
* made here and used against the endpoints above. A base URL with a path of
|
|
14
|
+
* its own, as an endpoint is, and its registrable domain is not the
|
|
15
|
+
* endpoints' — so the two are cross-site and a refresh cookie between them is
|
|
16
|
+
* a partitioned one. */
|
|
17
|
+
webui: "https://ui.example.test/ccmsg/",
|
|
18
|
+
/** A second web UI, sharing the endpoints' registrable domain: same-site, and
|
|
19
|
+
* still an origin of its own. A credential made here is a separate
|
|
20
|
+
* credential, and the cookie between it and an endpoint is not partitioned —
|
|
21
|
+
* one contract, two shapes, which is why both are written down. */
|
|
22
|
+
same_site_webui: "https://ui.example.ts.net/",
|
|
12
23
|
mid: "3f9c1a7b5e2d48069c1a7b5e2d480691/1841",
|
|
13
24
|
request_id: "1",
|
|
14
25
|
} as const;
|
package/src/fixtures/topics.ts
CHANGED
|
@@ -14,7 +14,17 @@ import type { InboxFrame } from "../messaging/message.ts";
|
|
|
14
14
|
import type { NotifyFrame } from "../messaging/notify.ts";
|
|
15
15
|
import { FIXTURE_IDS, FIXTURE_NOW } from "./ids.ts";
|
|
16
16
|
|
|
17
|
-
const {
|
|
17
|
+
const {
|
|
18
|
+
sid,
|
|
19
|
+
other_sid,
|
|
20
|
+
instance,
|
|
21
|
+
other_instance,
|
|
22
|
+
endpoint,
|
|
23
|
+
other_endpoint,
|
|
24
|
+
webui,
|
|
25
|
+
same_site_webui,
|
|
26
|
+
mid,
|
|
27
|
+
} = FIXTURE_IDS;
|
|
18
28
|
|
|
19
29
|
const WORKSPACE = "/repos/kawaz/ccmsg-protocol/main";
|
|
20
30
|
|
|
@@ -511,7 +521,7 @@ export const AUTH_RECORDS_FRAME = {
|
|
|
511
521
|
public_key: "pQECAyYgASFYIA",
|
|
512
522
|
user_handle: "dXNlci1oYW5kbGU",
|
|
513
523
|
endpoint,
|
|
514
|
-
|
|
524
|
+
webui,
|
|
515
525
|
sign_count: 0,
|
|
516
526
|
issued_label: "for kawaz",
|
|
517
527
|
device_label: "work laptop",
|
|
@@ -523,6 +533,25 @@ export const AUTH_RECORDS_FRAME = {
|
|
|
523
533
|
last_used_user_agent: "Mozilla/5.0",
|
|
524
534
|
},
|
|
525
535
|
},
|
|
536
|
+
{
|
|
537
|
+
// The same person at a second site, which shares the endpoint's
|
|
538
|
+
// registrable domain where the first does not. One credential per site,
|
|
539
|
+
// and the difference between the two is what decides whether the
|
|
540
|
+
// refresh cookie for a session made here is a partitioned one.
|
|
541
|
+
key: "credential/personal-1/Y3JlZC1pZC0y",
|
|
542
|
+
updated_at: FIXTURE_NOW,
|
|
543
|
+
body: {
|
|
544
|
+
kind: "credential",
|
|
545
|
+
sub: "personal-1",
|
|
546
|
+
credential_id: "Y3JlZC1pZC0y",
|
|
547
|
+
public_key: "pQECAyYgASFYIB",
|
|
548
|
+
user_handle: "dXNlci1oYW5kbGU",
|
|
549
|
+
endpoint,
|
|
550
|
+
webui: same_site_webui,
|
|
551
|
+
device_label: "phone",
|
|
552
|
+
registered_at: FIXTURE_NOW - 300_000,
|
|
553
|
+
},
|
|
554
|
+
},
|
|
526
555
|
],
|
|
527
556
|
},
|
|
528
557
|
} satisfies Static<typeof AuthRecordsFrame>;
|
|
@@ -542,6 +571,7 @@ export const AUTH_RECORDS_FAMILY_FRAME = {
|
|
|
542
571
|
kind: "token_family",
|
|
543
572
|
sub: "personal-1",
|
|
544
573
|
iss: instance,
|
|
574
|
+
webui,
|
|
545
575
|
access: { value: "YWNjZXNz", expires_at: FIXTURE_NOW + 10_000_000 },
|
|
546
576
|
refresh: { value: "cmVmcmVzaA", expires_at: FIXTURE_NOW + 600_000_000 },
|
|
547
577
|
last_refresh: {
|
package/src/identifiers.ts
CHANGED
|
@@ -43,6 +43,30 @@ export const InstanceId = Type.String({
|
|
|
43
43
|
});
|
|
44
44
|
export type InstanceId = Static<typeof InstanceId>;
|
|
45
45
|
|
|
46
|
+
/** A host as a browser serializes one: lowercase labels, or an address literal
|
|
47
|
+
* in its brackets. No uppercase, no userinfo, no empty label and no zone id —
|
|
48
|
+
* every one of those is either a second spelling of one host or a string no URL
|
|
49
|
+
* parser will take. */
|
|
50
|
+
const HOST =
|
|
51
|
+
"(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\\[[0-9a-f:.]+\\])";
|
|
52
|
+
|
|
53
|
+
/** A port in range, 1 to 65535. */
|
|
54
|
+
const PORT =
|
|
55
|
+
"(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])";
|
|
56
|
+
|
|
57
|
+
/** A scheme and authority, with the scheme's own port left unspelled — a
|
|
58
|
+
* browser omits it, so writing it would be a second name for one place. `tail`
|
|
59
|
+
* closes the pattern: the end of the string for an origin, a path for a base
|
|
60
|
+
* URL, and it is what the lookaheads read to know a port ended. */
|
|
61
|
+
function authority(tail: string): string {
|
|
62
|
+
const port = (its: string) => `(?::(?!${its}(?:/|$))${PORT})?`;
|
|
63
|
+
return `^(?:https://${HOST}${port("443")}|http://${HOST}${port("80")})${tail}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A base URL: an authority as above, then a path that ends in a slash and
|
|
67
|
+
* carries no query or fragment. */
|
|
68
|
+
const BASE_URL = authority("(?:/[^?#\\s]*)?/$");
|
|
69
|
+
|
|
46
70
|
/** Where an instance is published: the base URL everything it serves hangs
|
|
47
71
|
* under, ending in a slash and naming no route of its own.
|
|
48
72
|
*
|
|
@@ -57,8 +81,11 @@ export type InstanceId = Static<typeof InstanceId>;
|
|
|
57
81
|
*
|
|
58
82
|
* Compared as a whole string, path included (one origin
|
|
59
83
|
* may host several instances, so an origin-level comparison would confuse
|
|
60
|
-
* them).
|
|
61
|
-
* and `/ccmsg/`
|
|
84
|
+
* them). Every part of it is held to one spelling for that comparison's sake:
|
|
85
|
+
* the trailing slash is required, so `/ccmsg` and `/ccmsg/` are not two
|
|
86
|
+
* endpoints; the host is lowercase and the scheme's own port is left out, as a
|
|
87
|
+
* browser would write them; an internationalized host is spelled in punycode,
|
|
88
|
+
* which is what the wire carries anyway.
|
|
62
89
|
*
|
|
63
90
|
* Apart from `InstanceId` because the two answer different questions and change
|
|
64
91
|
* on different occasions. This is what a peer dials, what the TLS certificate
|
|
@@ -66,12 +93,54 @@ export type InstanceId = Static<typeof InstanceId>;
|
|
|
66
93
|
* as — trust is rooted in the URL and nowhere else. Which instance answers
|
|
67
94
|
* there is the id, which the handshake states and which an alias or a move does
|
|
68
95
|
* not alter. */
|
|
69
|
-
export const Endpoint = Type.String({
|
|
70
|
-
$id: "Endpoint",
|
|
71
|
-
pattern: "^https?://[^/?#\\s]+(/[^?#\\s]*)?/$",
|
|
72
|
-
});
|
|
96
|
+
export const Endpoint = Type.String({ $id: "Endpoint", pattern: BASE_URL });
|
|
73
97
|
export type Endpoint = Static<typeof Endpoint>;
|
|
74
98
|
|
|
99
|
+
/** Where the web UI is published: the base URL a person opens it at, ending in
|
|
100
|
+
* a slash and naming no route of its own (`https://ui.example/ccmsg/`).
|
|
101
|
+
*
|
|
102
|
+
* The counterpart of `Endpoint` on the other side of the wire. An endpoint says
|
|
103
|
+
* where an instance is dialed; this says where the page doing the dialing came
|
|
104
|
+
* from, and one of each is what a credential is made against. Spelled to the
|
|
105
|
+
* same rule as an endpoint, path and trailing slash included, because it is the
|
|
106
|
+
* same kind of value: a base URL that something is published under.
|
|
107
|
+
*
|
|
108
|
+
* **What is kept and what is compared are different sizes.** The whole URL is
|
|
109
|
+
* kept: it is where a person is sent, what an operator configures, and what
|
|
110
|
+
* they read back in a list of their own credentials. Every comparison this
|
|
111
|
+
* contract makes is of the origin (`originOf`) or the host (`rpIdOf`), because
|
|
112
|
+
* a browser writes neither a path in an `Origin` header nor one in a
|
|
113
|
+
* `clientDataJSON` — there is nothing finer on the wire to compare. Two web UIs
|
|
114
|
+
* under one origin are therefore one place to everything here. The origin is
|
|
115
|
+
* read off the URL where a header has to be matched rather than kept beside it
|
|
116
|
+
* as a second field that could disagree. */
|
|
117
|
+
export const WebUi = Type.String({ $id: "WebUi", pattern: BASE_URL });
|
|
118
|
+
export type WebUi = Static<typeof WebUi>;
|
|
119
|
+
|
|
120
|
+
/** Where a page was served from: a scheme and an authority and nothing else,
|
|
121
|
+
* spelled as a browser spells it in the `Origin` header and in a credential's
|
|
122
|
+
* `clientDataJSON` — no path, no trailing slash.
|
|
123
|
+
*
|
|
124
|
+
* Apart from `Endpoint` because the two are units of different size and answer
|
|
125
|
+
* different questions. An endpoint says which instance a person is admitted to
|
|
126
|
+
* and is compared with its path; an origin says which site the page in front of
|
|
127
|
+
* them came from, which is all the browser's same-origin rules know about and
|
|
128
|
+
* all a page's own script cannot lie about. One site may be the page for many
|
|
129
|
+
* endpoints, and one origin may carry many instances, so neither is derivable
|
|
130
|
+
* from the other.
|
|
131
|
+
*
|
|
132
|
+
* Held to the one spelling a browser serializes: a lowercase scheme, a
|
|
133
|
+
* lowercase host, and a port only where it is not the scheme's own. No
|
|
134
|
+
* userinfo, no path, no trailing slash, nothing else a URL may carry.
|
|
135
|
+
*
|
|
136
|
+
* The narrowness is the point rather than pedantry. Every use of this value is
|
|
137
|
+
* a whole-string comparison — against an `Origin` header, against a
|
|
138
|
+
* `clientDataJSON.origin`, against the members of a CORS answer — so a second
|
|
139
|
+
* spelling of one site would be a record that never matches the site it names,
|
|
140
|
+
* or an allowed origin that quietly admits nothing. */
|
|
141
|
+
export const Origin = Type.String({ $id: "Origin", pattern: authority("$") });
|
|
142
|
+
export type Origin = Static<typeof Origin>;
|
|
143
|
+
|
|
75
144
|
/** A delivery-frame id: `<instance id>/<counter>`, numbered by the instance
|
|
76
145
|
* that issued the frame. It exists so `reply_to` can point at one frame; it is
|
|
77
146
|
* not a cursor and carries no ordering across instances. */
|