@things-factory/auth-ui 10.1.31 → 10.1.33

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 (57) hide show
  1. package/dist-client/components/accept-invitation.d.ts +48 -0
  2. package/dist-client/components/accept-invitation.js +265 -0
  3. package/dist-client/components/accept-invitation.js.map +1 -0
  4. package/dist-client/components/appliance-editor.d.ts +3 -0
  5. package/dist-client/components/appliance-editor.js +102 -1
  6. package/dist-client/components/appliance-editor.js.map +1 -1
  7. package/dist-client/components/role-selector.js +23 -1
  8. package/dist-client/components/role-selector.js.map +1 -1
  9. package/dist-client/components/user-invite.d.ts +8 -0
  10. package/dist-client/components/user-invite.js +53 -6
  11. package/dist-client/components/user-invite.js.map +1 -1
  12. package/dist-client/entries/auth/accept-invitation.d.ts +3 -0
  13. package/dist-client/entries/auth/accept-invitation.js +10 -0
  14. package/dist-client/entries/auth/accept-invitation.js.map +1 -0
  15. package/dist-client/pages/invitation/invitation-list-query.test.d.ts +1 -0
  16. package/dist-client/pages/invitation/invitation-list-query.test.js +69 -0
  17. package/dist-client/pages/invitation/invitation-list-query.test.js.map +1 -0
  18. package/dist-client/pages/invitation/invitations.d.ts +63 -0
  19. package/dist-client/pages/invitation/invitations.js +282 -0
  20. package/dist-client/pages/invitation/invitations.js.map +1 -0
  21. package/dist-client/pages/role/role-list-page.d.ts +3 -2
  22. package/dist-client/pages/role/role-list-page.js +39 -6
  23. package/dist-client/pages/role/role-list-page.js.map +1 -1
  24. package/dist-client/route.js +3 -0
  25. package/dist-client/route.js.map +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/dist-client/utils/credential-panel-state.d.ts +51 -0
  28. package/dist-client/utils/credential-panel-state.js +49 -0
  29. package/dist-client/utils/credential-panel-state.js.map +1 -0
  30. package/dist-client/utils/credential-panel-state.test.d.ts +1 -0
  31. package/dist-client/utils/credential-panel-state.test.js +88 -0
  32. package/dist-client/utils/credential-panel-state.test.js.map +1 -0
  33. package/dist-client/utils/inherited-name-refusal.d.ts +44 -0
  34. package/dist-client/utils/inherited-name-refusal.js +46 -0
  35. package/dist-client/utils/inherited-name-refusal.js.map +1 -0
  36. package/dist-client/utils/inherited-name-refusal.test.d.ts +1 -0
  37. package/dist-client/utils/inherited-name-refusal.test.js +60 -0
  38. package/dist-client/utils/inherited-name-refusal.test.js.map +1 -0
  39. package/dist-client/utils/invitation-token.d.ts +28 -0
  40. package/dist-client/utils/invitation-token.js +40 -0
  41. package/dist-client/utils/invitation-token.js.map +1 -0
  42. package/dist-client/utils/invitation-token.test.d.ts +1 -0
  43. package/dist-client/utils/invitation-token.test.js +60 -0
  44. package/dist-client/utils/invitation-token.test.js.map +1 -0
  45. package/dist-client/utils/role-origin.d.ts +44 -0
  46. package/dist-client/utils/role-origin.js +47 -0
  47. package/dist-client/utils/role-origin.js.map +1 -0
  48. package/dist-client/utils/role-origin.test.d.ts +1 -0
  49. package/dist-client/utils/role-origin.test.js +60 -0
  50. package/dist-client/utils/role-origin.test.js.map +1 -0
  51. package/package.json +5 -5
  52. package/things-factory.config.js +1 -0
  53. package/translations/en.json +28 -0
  54. package/translations/ja.json +28 -0
  55. package/translations/ko.json +28 -0
  56. package/translations/ms.json +28 -0
  57. package/translations/zh.json +28 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What the appliance credential panel shows, as rules rather than as template conditions.
3
+ *
4
+ * ## Why these are not left inline
5
+ *
6
+ * Both of them decide whether a control that **takes a credential away** is on the screen, and
7
+ * both lean on a value that can be absent. Absence is where this goes wrong: a query that has
8
+ * not answered yet, a field the caller forgot to ask for, a refusal that arrives as data rather
9
+ * than as a throw. Each of those has to land on "no button" and not on "button", because the
10
+ * failure directions are not equal — a missing button is a feature somebody has to go and turn
11
+ * on, and a button that refuses when pressed is a product that looks broken.
12
+ *
13
+ * The template can then read as a template.
14
+ */
15
+ /** The shape of an Apollo result, as much of it as these rules read. */
16
+ type QueryResult = {
17
+ errors?: unknown;
18
+ data?: {
19
+ applianceCredentialRevocable?: boolean | null;
20
+ } | null;
21
+ };
22
+ /** The part of an appliance these rules read. */
23
+ type CredentialPanelSubject = {
24
+ credentialRevoked?: boolean | null;
25
+ } | null | undefined;
26
+ /**
27
+ * Does revoking work here, for this reader?
28
+ *
29
+ * `errorPolicy: 'all'` hands a refusal back as `errors` instead of throwing, and a reader who
30
+ * may list appliances but may not act on their credentials is refused exactly there. That reads
31
+ * the same as a plain `false` — no button either way — so both come back as `false` rather than
32
+ * as something the caller has to tell apart.
33
+ */
34
+ export declare function readRevocable(response: QueryResult | null | undefined): boolean;
35
+ /**
36
+ * Is the revoke button on the panel?
37
+ *
38
+ * Only where revoking works and the credential has not already been taken away. Pressing it on
39
+ * an already-revoked appliance would move the serial a second time and change nothing a reader
40
+ * can see, which reads as the first press not having worked.
41
+ */
42
+ export declare function showsRevokeButton(revocable: boolean, appliance: CredentialPanelSubject): boolean;
43
+ /**
44
+ * Does the panel say this credential was revoked?
45
+ *
46
+ * Independent of the switch. Turning revocation off later does not un-revoke what was revoked
47
+ * (ADR-0085 decision 2), so an installation that changes its mind must not be shown a blank box
48
+ * where a revoked credential is.
49
+ */
50
+ export declare function showsRevokedNote(appliance: CredentialPanelSubject): boolean;
51
+ export {};
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What the appliance credential panel shows, as rules rather than as template conditions.
3
+ *
4
+ * ## Why these are not left inline
5
+ *
6
+ * Both of them decide whether a control that **takes a credential away** is on the screen, and
7
+ * both lean on a value that can be absent. Absence is where this goes wrong: a query that has
8
+ * not answered yet, a field the caller forgot to ask for, a refusal that arrives as data rather
9
+ * than as a throw. Each of those has to land on "no button" and not on "button", because the
10
+ * failure directions are not equal — a missing button is a feature somebody has to go and turn
11
+ * on, and a button that refuses when pressed is a product that looks broken.
12
+ *
13
+ * The template can then read as a template.
14
+ */
15
+ /**
16
+ * Does revoking work here, for this reader?
17
+ *
18
+ * `errorPolicy: 'all'` hands a refusal back as `errors` instead of throwing, and a reader who
19
+ * may list appliances but may not act on their credentials is refused exactly there. That reads
20
+ * the same as a plain `false` — no button either way — so both come back as `false` rather than
21
+ * as something the caller has to tell apart.
22
+ */
23
+ export function readRevocable(response) {
24
+ if (!response || response.errors) {
25
+ return false;
26
+ }
27
+ return response.data?.applianceCredentialRevocable === true;
28
+ }
29
+ /**
30
+ * Is the revoke button on the panel?
31
+ *
32
+ * Only where revoking works and the credential has not already been taken away. Pressing it on
33
+ * an already-revoked appliance would move the serial a second time and change nothing a reader
34
+ * can see, which reads as the first press not having worked.
35
+ */
36
+ export function showsRevokeButton(revocable, appliance) {
37
+ return revocable && !appliance?.credentialRevoked;
38
+ }
39
+ /**
40
+ * Does the panel say this credential was revoked?
41
+ *
42
+ * Independent of the switch. Turning revocation off later does not un-revoke what was revoked
43
+ * (ADR-0085 decision 2), so an installation that changes its mind must not be shown a blank box
44
+ * where a revoked credential is.
45
+ */
46
+ export function showsRevokedNote(appliance) {
47
+ return !!appliance?.credentialRevoked;
48
+ }
49
+ //# sourceMappingURL=credential-panel-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-panel-state.js","sourceRoot":"","sources":["../../client/utils/credential-panel-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAaH;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,QAAwC;IACpE,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;QACjC,OAAO,KAAK,CAAA;IACd,CAAC;IAED,OAAO,QAAQ,CAAC,IAAI,EAAE,4BAA4B,KAAK,IAAI,CAAA;AAC7D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,SAAkB,EAAE,SAAiC;IACrF,OAAO,SAAS,IAAI,CAAC,SAAS,EAAE,iBAAiB,CAAA;AACnD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,SAAiC;IAChE,OAAO,CAAC,CAAC,SAAS,EAAE,iBAAiB,CAAA;AACvC,CAAC","sourcesContent":["/**\n * What the appliance credential panel shows, as rules rather than as template conditions.\n *\n * ## Why these are not left inline\n *\n * Both of them decide whether a control that **takes a credential away** is on the screen, and\n * both lean on a value that can be absent. Absence is where this goes wrong: a query that has\n * not answered yet, a field the caller forgot to ask for, a refusal that arrives as data rather\n * than as a throw. Each of those has to land on \"no button\" and not on \"button\", because the\n * failure directions are not equal — a missing button is a feature somebody has to go and turn\n * on, and a button that refuses when pressed is a product that looks broken.\n *\n * The template can then read as a template.\n */\n\n/** The shape of an Apollo result, as much of it as these rules read. */\ntype QueryResult = {\n errors?: unknown\n data?: { applianceCredentialRevocable?: boolean | null } | null\n}\n\n/** The part of an appliance these rules read. */\ntype CredentialPanelSubject = {\n credentialRevoked?: boolean | null\n} | null | undefined\n\n/**\n * Does revoking work here, for this reader?\n *\n * `errorPolicy: 'all'` hands a refusal back as `errors` instead of throwing, and a reader who\n * may list appliances but may not act on their credentials is refused exactly there. That reads\n * the same as a plain `false` — no button either way — so both come back as `false` rather than\n * as something the caller has to tell apart.\n */\nexport function readRevocable(response: QueryResult | null | undefined): boolean {\n if (!response || response.errors) {\n return false\n }\n\n return response.data?.applianceCredentialRevocable === true\n}\n\n/**\n * Is the revoke button on the panel?\n *\n * Only where revoking works and the credential has not already been taken away. Pressing it on\n * an already-revoked appliance would move the serial a second time and change nothing a reader\n * can see, which reads as the first press not having worked.\n */\nexport function showsRevokeButton(revocable: boolean, appliance: CredentialPanelSubject): boolean {\n return revocable && !appliance?.credentialRevoked\n}\n\n/**\n * Does the panel say this credential was revoked?\n *\n * Independent of the switch. Turning revocation off later does not un-revoke what was revoked\n * (ADR-0085 decision 2), so an installation that changes its mind must not be shown a blank box\n * where a revoked credential is.\n */\nexport function showsRevokedNote(appliance: CredentialPanelSubject): boolean {\n return !!appliance?.credentialRevoked\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import { readRevocable, showsRevokeButton, showsRevokedNote } from './credential-panel-state';
2
+ /**
3
+ * The two rules that decide whether a control for taking a credential away is on the screen.
4
+ *
5
+ * Most of these are about absence, because that is where the rule can fall the wrong way: a
6
+ * query that has not answered, a field nobody asked for, a refusal that arrives as data. The
7
+ * two directions are not equally bad — a missing button is a feature to turn on, a button that
8
+ * refuses when pressed is a product that looks broken — so every absent case has to land on
9
+ * "no button".
10
+ */
11
+ describe('does revoking work here', () => {
12
+ it('yes only for a plain true', () => {
13
+ expect(readRevocable({ data: { applianceCredentialRevocable: true } })).toBe(true);
14
+ });
15
+ it('no for a plain false', () => {
16
+ expect(readRevocable({ data: { applianceCredentialRevocable: false } })).toBe(false);
17
+ });
18
+ it('no when the read was refused', () => {
19
+ /*
20
+ * ⚠ How a reader without the privilege arrives here. errorPolicy 'all' hands the refusal
21
+ * back as data, so nothing throws and the value beside it is undefined.
22
+ */
23
+ expect(readRevocable({ errors: [{ message: 'forbidden' }], data: null })).toBe(false);
24
+ expect(readRevocable({ errors: [{ message: 'forbidden' }], data: { applianceCredentialRevocable: true } })).toBe(false);
25
+ });
26
+ it('no while nothing has answered yet', () => {
27
+ expect(readRevocable(undefined)).toBe(false);
28
+ expect(readRevocable(null)).toBe(false);
29
+ expect(readRevocable({})).toBe(false);
30
+ expect(readRevocable({ data: {} })).toBe(false);
31
+ });
32
+ it('does not take a truthy value for true', () => {
33
+ /*
34
+ * The field is declared Boolean, so anything else came from somewhere that is not this
35
+ * schema. Leaning the other way would put the button up on a shaped-but-wrong answer.
36
+ */
37
+ expect(readRevocable({ data: { applianceCredentialRevocable: 1 } })).toBe(false);
38
+ expect(readRevocable({ data: { applianceCredentialRevocable: 'true' } })).toBe(false);
39
+ });
40
+ });
41
+ describe('is the revoke button on the panel', () => {
42
+ it('yes where revoking works and the credential is live', () => {
43
+ expect(showsRevokeButton(true, { credentialRevoked: false })).toBe(true);
44
+ });
45
+ it('no where the installation never turned revoking on', () => {
46
+ /* ⚠ The default. The mutation behind the button answers 404 here. */
47
+ expect(showsRevokeButton(false, { credentialRevoked: false })).toBe(false);
48
+ });
49
+ it('no once it has already been revoked', () => {
50
+ /*
51
+ * A second press moves the serial again and changes nothing the reader can see, which reads
52
+ * as the first press not having worked.
53
+ */
54
+ expect(showsRevokeButton(true, { credentialRevoked: true })).toBe(false);
55
+ });
56
+ it('shows it when the field is absent rather than false', () => {
57
+ /*
58
+ * Absent means not revoked — an appliance loaded by a query that did not ask for the field,
59
+ * and the state a freshly registered one is in. Reading absence as revoked would take the
60
+ * button away from every appliance that still has a live credential.
61
+ */
62
+ expect(showsRevokeButton(true, {})).toBe(true);
63
+ expect(showsRevokeButton(true, { credentialRevoked: null })).toBe(true);
64
+ });
65
+ it('does not fall over before the record has loaded', () => {
66
+ expect(showsRevokeButton(true, null)).toBe(true);
67
+ expect(showsRevokeButton(false, undefined)).toBe(false);
68
+ });
69
+ });
70
+ describe('does the panel say this was revoked', () => {
71
+ it('only when the appliance says so', () => {
72
+ expect(showsRevokedNote({ credentialRevoked: true })).toBe(true);
73
+ expect(showsRevokedNote({ credentialRevoked: false })).toBe(false);
74
+ expect(showsRevokedNote({})).toBe(false);
75
+ expect(showsRevokedNote(null)).toBe(false);
76
+ });
77
+ it('says so even where revoking has since been turned off', () => {
78
+ /*
79
+ * ⚠ ADR-0085 decision 2 — the switch governs the door, not what was already done. The note
80
+ * reads the appliance and never the switch, so this holds by construction; the case is here
81
+ * because tying the note to the switch is the obvious shortcut and it would leave a revoked
82
+ * credential looking like one that was never issued.
83
+ */
84
+ expect(showsRevokedNote({ credentialRevoked: true })).toBe(true);
85
+ expect(showsRevokeButton(false, { credentialRevoked: true })).toBe(false);
86
+ });
87
+ });
88
+ //# sourceMappingURL=credential-panel-state.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-panel-state.test.js","sourceRoot":"","sources":["../../client/utils/credential-panel-state.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { readRevocable, showsRevokeButton, showsRevokedNote } from './credential-panel-state'\n\n/**\n * The two rules that decide whether a control for taking a credential away is on the screen.\n *\n * Most of these are about absence, because that is where the rule can fall the wrong way: a\n * query that has not answered, a field nobody asked for, a refusal that arrives as data. The\n * two directions are not equally bad — a missing button is a feature to turn on, a button that\n * refuses when pressed is a product that looks broken — so every absent case has to land on\n * \"no button\".\n */\n\ndescribe('does revoking work here', () => {\n it('yes only for a plain true', () => {\n expect(readRevocable({ data: { applianceCredentialRevocable: true } })).toBe(true)\n })\n\n it('no for a plain false', () => {\n expect(readRevocable({ data: { applianceCredentialRevocable: false } })).toBe(false)\n })\n\n it('no when the read was refused', () => {\n /*\n * ⚠ How a reader without the privilege arrives here. errorPolicy 'all' hands the refusal\n * back as data, so nothing throws and the value beside it is undefined.\n */\n expect(readRevocable({ errors: [{ message: 'forbidden' }], data: null })).toBe(false)\n expect(readRevocable({ errors: [{ message: 'forbidden' }], data: { applianceCredentialRevocable: true } })).toBe(\n false\n )\n })\n\n it('no while nothing has answered yet', () => {\n expect(readRevocable(undefined)).toBe(false)\n expect(readRevocable(null)).toBe(false)\n expect(readRevocable({})).toBe(false)\n expect(readRevocable({ data: {} })).toBe(false)\n })\n\n it('does not take a truthy value for true', () => {\n /*\n * The field is declared Boolean, so anything else came from somewhere that is not this\n * schema. Leaning the other way would put the button up on a shaped-but-wrong answer.\n */\n expect(readRevocable({ data: { applianceCredentialRevocable: 1 as any } })).toBe(false)\n expect(readRevocable({ data: { applianceCredentialRevocable: 'true' as any } })).toBe(false)\n })\n})\n\ndescribe('is the revoke button on the panel', () => {\n it('yes where revoking works and the credential is live', () => {\n expect(showsRevokeButton(true, { credentialRevoked: false })).toBe(true)\n })\n\n it('no where the installation never turned revoking on', () => {\n /* ⚠ The default. The mutation behind the button answers 404 here. */\n expect(showsRevokeButton(false, { credentialRevoked: false })).toBe(false)\n })\n\n it('no once it has already been revoked', () => {\n /*\n * A second press moves the serial again and changes nothing the reader can see, which reads\n * as the first press not having worked.\n */\n expect(showsRevokeButton(true, { credentialRevoked: true })).toBe(false)\n })\n\n it('shows it when the field is absent rather than false', () => {\n /*\n * Absent means not revoked — an appliance loaded by a query that did not ask for the field,\n * and the state a freshly registered one is in. Reading absence as revoked would take the\n * button away from every appliance that still has a live credential.\n */\n expect(showsRevokeButton(true, {})).toBe(true)\n expect(showsRevokeButton(true, { credentialRevoked: null })).toBe(true)\n })\n\n it('does not fall over before the record has loaded', () => {\n expect(showsRevokeButton(true, null)).toBe(true)\n expect(showsRevokeButton(false, undefined)).toBe(false)\n })\n})\n\ndescribe('does the panel say this was revoked', () => {\n it('only when the appliance says so', () => {\n expect(showsRevokedNote({ credentialRevoked: true })).toBe(true)\n expect(showsRevokedNote({ credentialRevoked: false })).toBe(false)\n expect(showsRevokedNote({})).toBe(false)\n expect(showsRevokedNote(null)).toBe(false)\n })\n\n it('says so even where revoking has since been turned off', () => {\n /*\n * ⚠ ADR-0085 decision 2 — the switch governs the door, not what was already done. The note\n * reads the appliance and never the switch, so this holds by construction; the case is here\n * because tying the note to the switch is the obvious shortcut and it would leave a revoked\n * credential looking like one that was never issued.\n */\n expect(showsRevokedNote({ credentialRevoked: true })).toBe(true)\n expect(showsRevokeButton(false, { credentialRevoked: true })).toBe(false)\n })\n})\n"]}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Reading back the one refusal a screen can offer a way out of. (Pure rule.)
3
+ *
4
+ * ## Why this one is different from the others
5
+ *
6
+ * Most refusals end the attempt: the operator reads them and changes what they were doing. This
7
+ * one does not. A role name that is already visible through the parent domain is refused
8
+ * **because the platform will not let it happen silently**, not because it must not happen —
9
+ * standing up the same names in this domain is the first step of moving off an inherited
10
+ * structure (ADR-0062 decisions 2 and 3).
11
+ *
12
+ * So the screen has to recognise this refusal specifically, tell the operator which domain the
13
+ * other role stands in, and offer to go again saying so. A screen that only printed the sentence
14
+ * would leave the way out reachable by nobody.
15
+ *
16
+ * ## Why the code and not the sentence
17
+ *
18
+ * The sentence is built in the caller's language, so matching on its text works until somebody
19
+ * is reading the screen in Korean. `extensions.refusal.code` is the contract and travels
20
+ * unchanged (ADR-0042, ADR-0054).
21
+ */
22
+ /** What the refusal carries: the name that was asked for, and where the other one stands. */
23
+ export type InheritedNameRefusal = {
24
+ name: string;
25
+ domain: string;
26
+ };
27
+ type GraphQLErrorish = {
28
+ extensions?: {
29
+ refusal?: {
30
+ code?: unknown;
31
+ params?: Record<string, unknown> | null;
32
+ } | null;
33
+ } | null;
34
+ };
35
+ /**
36
+ * The refusal's values when this response is that refusal, and nothing otherwise.
37
+ *
38
+ * `null` for every other outcome — a different refusal, a plain error, a response that succeeded.
39
+ * The caller treats `null` as "there is no way out to offer", which is right for all of them.
40
+ */
41
+ export declare function standsInheritedRefusal(response: {
42
+ errors?: GraphQLErrorish[] | null;
43
+ } | null | undefined): InheritedNameRefusal | null;
44
+ export {};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Reading back the one refusal a screen can offer a way out of. (Pure rule.)
3
+ *
4
+ * ## Why this one is different from the others
5
+ *
6
+ * Most refusals end the attempt: the operator reads them and changes what they were doing. This
7
+ * one does not. A role name that is already visible through the parent domain is refused
8
+ * **because the platform will not let it happen silently**, not because it must not happen —
9
+ * standing up the same names in this domain is the first step of moving off an inherited
10
+ * structure (ADR-0062 decisions 2 and 3).
11
+ *
12
+ * So the screen has to recognise this refusal specifically, tell the operator which domain the
13
+ * other role stands in, and offer to go again saying so. A screen that only printed the sentence
14
+ * would leave the way out reachable by nobody.
15
+ *
16
+ * ## Why the code and not the sentence
17
+ *
18
+ * The sentence is built in the caller's language, so matching on its text works until somebody
19
+ * is reading the screen in Korean. `extensions.refusal.code` is the contract and travels
20
+ * unchanged (ADR-0042, ADR-0054).
21
+ */
22
+ const CODE = 'role-name-stands-inherited';
23
+ /**
24
+ * The refusal's values when this response is that refusal, and nothing otherwise.
25
+ *
26
+ * `null` for every other outcome — a different refusal, a plain error, a response that succeeded.
27
+ * The caller treats `null` as "there is no way out to offer", which is right for all of them.
28
+ */
29
+ export function standsInheritedRefusal(response) {
30
+ const refusal = (response?.errors || []).map(error => error?.extensions?.refusal).find(r => r?.code === CODE);
31
+ if (!refusal) {
32
+ return null;
33
+ }
34
+ const params = refusal.params || {};
35
+ /*
36
+ * The name is what the operator typed, so a refusal without it is not one this screen can put
37
+ * a question to. The domain may be empty — a parent row with no name loaded — and the question
38
+ * still reads, so only the name is required.
39
+ */
40
+ const name = typeof params.name === 'string' ? params.name : '';
41
+ if (!name) {
42
+ return null;
43
+ }
44
+ return { name, domain: typeof params.domain === 'string' ? params.domain : '' };
45
+ }
46
+ //# sourceMappingURL=inherited-name-refusal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inherited-name-refusal.js","sourceRoot":"","sources":["../../client/utils/inherited-name-refusal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAQH,MAAM,IAAI,GAAG,4BAA4B,CAAA;AAMzC;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CACpC,QAAkE;IAElE,MAAM,OAAO,GAAG,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,KAAK,IAAI,CAAC,CAAA;IAE7G,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,EAAE,CAAA;IAEnC;;;;OAIG;IACH,MAAM,IAAI,GAAG,OAAO,MAAM,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAA;IAE/D,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAA;AACjF,CAAC","sourcesContent":["/**\n * Reading back the one refusal a screen can offer a way out of. (Pure rule.)\n *\n * ## Why this one is different from the others\n *\n * Most refusals end the attempt: the operator reads them and changes what they were doing. This\n * one does not. A role name that is already visible through the parent domain is refused\n * **because the platform will not let it happen silently**, not because it must not happen —\n * standing up the same names in this domain is the first step of moving off an inherited\n * structure (ADR-0062 decisions 2 and 3).\n *\n * So the screen has to recognise this refusal specifically, tell the operator which domain the\n * other role stands in, and offer to go again saying so. A screen that only printed the sentence\n * would leave the way out reachable by nobody.\n *\n * ## Why the code and not the sentence\n *\n * The sentence is built in the caller's language, so matching on its text works until somebody\n * is reading the screen in Korean. `extensions.refusal.code` is the contract and travels\n * unchanged (ADR-0042, ADR-0054).\n */\n\n/** What the refusal carries: the name that was asked for, and where the other one stands. */\nexport type InheritedNameRefusal = {\n name: string\n domain: string\n}\n\nconst CODE = 'role-name-stands-inherited'\n\ntype GraphQLErrorish = {\n extensions?: { refusal?: { code?: unknown; params?: Record<string, unknown> | null } | null } | null\n}\n\n/**\n * The refusal's values when this response is that refusal, and nothing otherwise.\n *\n * `null` for every other outcome — a different refusal, a plain error, a response that succeeded.\n * The caller treats `null` as \"there is no way out to offer\", which is right for all of them.\n */\nexport function standsInheritedRefusal(\n response: { errors?: GraphQLErrorish[] | null } | null | undefined\n): InheritedNameRefusal | null {\n const refusal = (response?.errors || []).map(error => error?.extensions?.refusal).find(r => r?.code === CODE)\n\n if (!refusal) {\n return null\n }\n\n const params = refusal.params || {}\n\n /*\n * The name is what the operator typed, so a refusal without it is not one this screen can put\n * a question to. The domain may be empty — a parent row with no name loaded — and the question\n * still reads, so only the name is required.\n */\n const name = typeof params.name === 'string' ? params.name : ''\n\n if (!name) {\n return null\n }\n\n return { name, domain: typeof params.domain === 'string' ? params.domain : '' }\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { standsInheritedRefusal } from './inherited-name-refusal';
2
+ /**
3
+ * Recognising the refusal the screen can offer a way out of.
4
+ *
5
+ * Both directions cost. Missing it leaves the operator reading a sentence about a domain they
6
+ * may not have known existed, with no way to act on it — and standing up the same names here is
7
+ * the only route off an inherited structure. Seeing it where it is not would offer to force
8
+ * through a refusal that means something else.
9
+ */
10
+ const refusalResponse = (code, params) => ({
11
+ errors: [{ extensions: { refusal: { code, params } } }]
12
+ });
13
+ describe('the refusal that has a way out', () => {
14
+ it('is read back with the name and the domain', () => {
15
+ expect(standsInheritedRefusal(refusalResponse('role-name-stands-inherited', { name: 'approver', domain: 'system' }))).toEqual({ name: 'approver', domain: 'system' });
16
+ });
17
+ it('is found among other errors', () => {
18
+ const response = {
19
+ errors: [
20
+ { extensions: { refusal: { code: 'something-else', params: {} } } },
21
+ { extensions: { refusal: { code: 'role-name-stands-inherited', params: { name: 'approver', domain: 'system' } } } }
22
+ ]
23
+ };
24
+ expect(standsInheritedRefusal(response)).toMatchObject({ name: 'approver' });
25
+ });
26
+ it('still reads when the domain could not be named', () => {
27
+ /* The question is worth asking without it; the name is what makes it answerable. */
28
+ expect(standsInheritedRefusal(refusalResponse('role-name-stands-inherited', { name: 'approver' }))).toEqual({
29
+ name: 'approver',
30
+ domain: ''
31
+ });
32
+ });
33
+ });
34
+ describe('everything else', () => {
35
+ it('a different refusal', () => {
36
+ /*
37
+ * ⚠ `role-name-taken-here` is the one with no way out — two rows in one domain would be
38
+ * indistinguishable. Offering to force it would be offering something that cannot happen.
39
+ */
40
+ expect(standsInheritedRefusal(refusalResponse('role-name-taken-here', { name: 'approver' }))).toBeNull();
41
+ });
42
+ it('a plain error with no refusal on it', () => {
43
+ expect(standsInheritedRefusal({ errors: [{ extensions: { code: 'INTERNAL_SERVER_ERROR' } }] })).toBeNull();
44
+ });
45
+ it('a response that worked', () => {
46
+ expect(standsInheritedRefusal({})).toBeNull();
47
+ expect(standsInheritedRefusal({ errors: [] })).toBeNull();
48
+ expect(standsInheritedRefusal(null)).toBeNull();
49
+ expect(standsInheritedRefusal(undefined)).toBeNull();
50
+ });
51
+ it('the right code with nothing to put in the question', () => {
52
+ /* Without the name there is no question to ask, so there is no way out to offer. */
53
+ expect(standsInheritedRefusal(refusalResponse('role-name-stands-inherited', {}))).toBeNull();
54
+ expect(standsInheritedRefusal(refusalResponse('role-name-stands-inherited', null))).toBeNull();
55
+ });
56
+ it('does not take a code-shaped value that is not a string', () => {
57
+ expect(standsInheritedRefusal(refusalResponse('role-name-stands-inherited', { name: 42 }))).toBeNull();
58
+ });
59
+ });
60
+ //# sourceMappingURL=inherited-name-refusal.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inherited-name-refusal.test.js","sourceRoot":"","sources":["../../client/utils/inherited-name-refusal.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { standsInheritedRefusal } from './inherited-name-refusal'\n\n/**\n * Recognising the refusal the screen can offer a way out of.\n *\n * Both directions cost. Missing it leaves the operator reading a sentence about a domain they\n * may not have known existed, with no way to act on it — and standing up the same names here is\n * the only route off an inherited structure. Seeing it where it is not would offer to force\n * through a refusal that means something else.\n */\n\nconst refusalResponse = (code: string, params: any) => ({\n errors: [{ extensions: { refusal: { code, params } } }]\n})\n\ndescribe('the refusal that has a way out', () => {\n it('is read back with the name and the domain', () => {\n expect(\n standsInheritedRefusal(refusalResponse('role-name-stands-inherited', { name: 'approver', domain: 'system' }))\n ).toEqual({ name: 'approver', domain: 'system' })\n })\n\n it('is found among other errors', () => {\n const response = {\n errors: [\n { extensions: { refusal: { code: 'something-else', params: {} } } },\n { extensions: { refusal: { code: 'role-name-stands-inherited', params: { name: 'approver', domain: 'system' } } } }\n ]\n }\n\n expect(standsInheritedRefusal(response)).toMatchObject({ name: 'approver' })\n })\n\n it('still reads when the domain could not be named', () => {\n /* The question is worth asking without it; the name is what makes it answerable. */\n expect(standsInheritedRefusal(refusalResponse('role-name-stands-inherited', { name: 'approver' }))).toEqual({\n name: 'approver',\n domain: ''\n })\n })\n})\n\ndescribe('everything else', () => {\n it('a different refusal', () => {\n /*\n * ⚠ `role-name-taken-here` is the one with no way out — two rows in one domain would be\n * indistinguishable. Offering to force it would be offering something that cannot happen.\n */\n expect(standsInheritedRefusal(refusalResponse('role-name-taken-here', { name: 'approver' }))).toBeNull()\n })\n\n it('a plain error with no refusal on it', () => {\n expect(standsInheritedRefusal({ errors: [{ extensions: { code: 'INTERNAL_SERVER_ERROR' } as any }] })).toBeNull()\n })\n\n it('a response that worked', () => {\n expect(standsInheritedRefusal({})).toBeNull()\n expect(standsInheritedRefusal({ errors: [] })).toBeNull()\n expect(standsInheritedRefusal(null)).toBeNull()\n expect(standsInheritedRefusal(undefined)).toBeNull()\n })\n\n it('the right code with nothing to put in the question', () => {\n /* Without the name there is no question to ask, so there is no way out to offer. */\n expect(standsInheritedRefusal(refusalResponse('role-name-stands-inherited', {}))).toBeNull()\n expect(standsInheritedRefusal(refusalResponse('role-name-stands-inherited', null))).toBeNull()\n })\n\n it('does not take a code-shaped value that is not a string', () => {\n expect(standsInheritedRefusal(refusalResponse('role-name-stands-inherited', { name: 42 }))).toBeNull()\n })\n})\n"]}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Taking the invitation token out of the link, and out of the address bar. (Pure rule.)
3
+ *
4
+ * ## Why the token is in the fragment
5
+ *
6
+ * A browser does not send what follows `#`, so a link shaped `…/accept-invitation#<token>`
7
+ * reaches the server as `/accept-invitation` and nothing else. The token never appears in an
8
+ * access log, a proxy log, or a `Referer` sent onward to some other site.
9
+ *
10
+ * That leaves the places the browser itself keeps it: the address bar and the history entry.
11
+ * Both are cleared by replacing the URL with the one this returns as `clean`, which is why the
12
+ * two answers come back together — reading the token and removing it are one act, and a caller
13
+ * that did the first without the second would leave it on screen.
14
+ *
15
+ * ## What it refuses to read
16
+ *
17
+ * A token is hex from `crypto.randomBytes(16)`. Anything else in the fragment is not one, and
18
+ * taking it anyway would send whatever a link happened to carry — a tracking fragment, a page
19
+ * anchor — to the server as a credential attempt. It answers empty instead, and the page shows
20
+ * the box where the code from the mail can be pasted.
21
+ */
22
+ export type InvitationLink = {
23
+ /** The token, or an empty string when the link carries none this rule will read. */
24
+ token: string;
25
+ /** The same URL without its fragment, for the caller to replace the address with. */
26
+ clean: string;
27
+ };
28
+ export declare function readInvitationToken(href: string): InvitationLink;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Taking the invitation token out of the link, and out of the address bar. (Pure rule.)
3
+ *
4
+ * ## Why the token is in the fragment
5
+ *
6
+ * A browser does not send what follows `#`, so a link shaped `…/accept-invitation#<token>`
7
+ * reaches the server as `/accept-invitation` and nothing else. The token never appears in an
8
+ * access log, a proxy log, or a `Referer` sent onward to some other site.
9
+ *
10
+ * That leaves the places the browser itself keeps it: the address bar and the history entry.
11
+ * Both are cleared by replacing the URL with the one this returns as `clean`, which is why the
12
+ * two answers come back together — reading the token and removing it are one act, and a caller
13
+ * that did the first without the second would leave it on screen.
14
+ *
15
+ * ## What it refuses to read
16
+ *
17
+ * A token is hex from `crypto.randomBytes(16)`. Anything else in the fragment is not one, and
18
+ * taking it anyway would send whatever a link happened to carry — a tracking fragment, a page
19
+ * anchor — to the server as a credential attempt. It answers empty instead, and the page shows
20
+ * the box where the code from the mail can be pasted.
21
+ */
22
+ /** 16 bytes as hex, which is what `makeInvitationToken` produces. */
23
+ const TOKEN = /^[0-9a-f]{32}$/;
24
+ export function readInvitationToken(href) {
25
+ if (!href) {
26
+ return { token: '', clean: '' };
27
+ }
28
+ const hash = href.indexOf('#');
29
+ if (hash < 0) {
30
+ return { token: '', clean: href };
31
+ }
32
+ const clean = href.slice(0, hash);
33
+ /*
34
+ * `#invite=<token>` as well as a bare `#<token>`. Both shapes turn up in mail clients that
35
+ * rewrite links, and neither costs anything to accept.
36
+ */
37
+ const raw = decodeURIComponent(href.slice(hash + 1)).replace(/^invite=/, '');
38
+ return { token: TOKEN.test(raw) ? raw : '', clean };
39
+ }
40
+ //# sourceMappingURL=invitation-token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invitation-token.js","sourceRoot":"","sources":["../../client/utils/invitation-token.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,qEAAqE;AACrE,MAAM,KAAK,GAAG,gBAAgB,CAAA;AAS9B,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,CAAA;IACjC,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;IAE9B,IAAI,IAAI,GAAG,CAAC,EAAE,CAAC;QACb,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;IACnC,CAAC;IAED,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAA;IAEjC;;;OAGG;IACH,MAAM,GAAG,GAAG,kBAAkB,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAA;IAE5E,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,KAAK,EAAE,CAAA;AACrD,CAAC","sourcesContent":["/**\n * Taking the invitation token out of the link, and out of the address bar. (Pure rule.)\n *\n * ## Why the token is in the fragment\n *\n * A browser does not send what follows `#`, so a link shaped `…/accept-invitation#<token>`\n * reaches the server as `/accept-invitation` and nothing else. The token never appears in an\n * access log, a proxy log, or a `Referer` sent onward to some other site.\n *\n * That leaves the places the browser itself keeps it: the address bar and the history entry.\n * Both are cleared by replacing the URL with the one this returns as `clean`, which is why the\n * two answers come back together — reading the token and removing it are one act, and a caller\n * that did the first without the second would leave it on screen.\n *\n * ## What it refuses to read\n *\n * A token is hex from `crypto.randomBytes(16)`. Anything else in the fragment is not one, and\n * taking it anyway would send whatever a link happened to carry — a tracking fragment, a page\n * anchor — to the server as a credential attempt. It answers empty instead, and the page shows\n * the box where the code from the mail can be pasted.\n */\n\n/** 16 bytes as hex, which is what `makeInvitationToken` produces. */\nconst TOKEN = /^[0-9a-f]{32}$/\n\nexport type InvitationLink = {\n /** The token, or an empty string when the link carries none this rule will read. */\n token: string\n /** The same URL without its fragment, for the caller to replace the address with. */\n clean: string\n}\n\nexport function readInvitationToken(href: string): InvitationLink {\n if (!href) {\n return { token: '', clean: '' }\n }\n\n const hash = href.indexOf('#')\n\n if (hash < 0) {\n return { token: '', clean: href }\n }\n\n const clean = href.slice(0, hash)\n\n /*\n * `#invite=<token>` as well as a bare `#<token>`. Both shapes turn up in mail clients that\n * rewrite links, and neither costs anything to accept.\n */\n const raw = decodeURIComponent(href.slice(hash + 1)).replace(/^invite=/, '')\n\n return { token: TOKEN.test(raw) ? raw : '', clean }\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { readInvitationToken } from './invitation-token';
2
+ /**
3
+ * Reading the invitation token out of the link, and handing back the link without it.
4
+ *
5
+ * The two answers travel together because removing the token is not optional: the fragment keeps
6
+ * it out of server and proxy logs, and replacing the address is what keeps it out of the browser
7
+ * history and off the screen. A caller that read it and forgot to replace would undo the point.
8
+ */
9
+ const TOKEN = '0123456789abcdef0123456789abcdef';
10
+ const PAGE = 'https://acme.example.com/accept-invitation';
11
+ describe('a link with a token in it', () => {
12
+ it('reads a bare fragment', () => {
13
+ expect(readInvitationToken(`${PAGE}#${TOKEN}`)).toEqual({ token: TOKEN, clean: PAGE });
14
+ });
15
+ it('reads the named form some mail clients produce', () => {
16
+ expect(readInvitationToken(`${PAGE}#invite=${TOKEN}`)).toEqual({ token: TOKEN, clean: PAGE });
17
+ });
18
+ it('reads one that arrived percent-encoded', () => {
19
+ expect(readInvitationToken(`${PAGE}#invite%3D${TOKEN}`).token).toEqual(TOKEN);
20
+ });
21
+ it('hands back the address without the fragment', () => {
22
+ /* ⚠ What the caller replaces the address with. Without it the token stays on screen and in
23
+ this history entry, which is the half of the hiding the fragment does not do by itself. */
24
+ expect(readInvitationToken(`${PAGE}?x=1#${TOKEN}`).clean).toEqual(`${PAGE}?x=1`);
25
+ });
26
+ });
27
+ describe('a link with nothing usable in it', () => {
28
+ it('has no fragment at all', () => {
29
+ expect(readInvitationToken(PAGE)).toEqual({ token: '', clean: PAGE });
30
+ });
31
+ it('has an empty fragment', () => {
32
+ expect(readInvitationToken(`${PAGE}#`)).toEqual({ token: '', clean: PAGE });
33
+ });
34
+ it('⚠ carries something that is not a token', () => {
35
+ /*
36
+ * ⚠ A page anchor, a tracking fragment, anything a link happened to pick up. Sending it as a
37
+ * credential attempt would be this page volunteering whatever it was given.
38
+ */
39
+ expect(readInvitationToken(`${PAGE}#section-2`).token).toEqual('');
40
+ expect(readInvitationToken(`${PAGE}#utm_source=mail`).token).toEqual('');
41
+ expect(readInvitationToken(`${PAGE}#<script>`).token).toEqual('');
42
+ });
43
+ it('carries something token-shaped but the wrong length', () => {
44
+ expect(readInvitationToken(`${PAGE}#0123456789abcdef`).token).toEqual('');
45
+ expect(readInvitationToken(`${PAGE}#${TOKEN}ff`).token).toEqual('');
46
+ });
47
+ it('carries hex in the wrong case', () => {
48
+ /* `makeInvitationToken` produces lower-case hex. Anything else did not come from us. */
49
+ expect(readInvitationToken(`${PAGE}#${TOKEN.toUpperCase()}`).token).toEqual('');
50
+ });
51
+ it('still hands back a clean address to replace with', () => {
52
+ /* Even a fragment that is not ours should not be left sitting in the bar for this page. */
53
+ expect(readInvitationToken(`${PAGE}#section-2`).clean).toEqual(PAGE);
54
+ });
55
+ it('is given nothing at all', () => {
56
+ expect(readInvitationToken('')).toEqual({ token: '', clean: '' });
57
+ expect(readInvitationToken(undefined)).toEqual({ token: '', clean: '' });
58
+ });
59
+ });
60
+ //# sourceMappingURL=invitation-token.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invitation-token.test.js","sourceRoot":"","sources":["../../client/utils/invitation-token.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { readInvitationToken } from './invitation-token'\n\n/**\n * Reading the invitation token out of the link, and handing back the link without it.\n *\n * The two answers travel together because removing the token is not optional: the fragment keeps\n * it out of server and proxy logs, and replacing the address is what keeps it out of the browser\n * history and off the screen. A caller that read it and forgot to replace would undo the point.\n */\n\nconst TOKEN = '0123456789abcdef0123456789abcdef'\nconst PAGE = 'https://acme.example.com/accept-invitation'\n\ndescribe('a link with a token in it', () => {\n it('reads a bare fragment', () => {\n expect(readInvitationToken(`${PAGE}#${TOKEN}`)).toEqual({ token: TOKEN, clean: PAGE })\n })\n\n it('reads the named form some mail clients produce', () => {\n expect(readInvitationToken(`${PAGE}#invite=${TOKEN}`)).toEqual({ token: TOKEN, clean: PAGE })\n })\n\n it('reads one that arrived percent-encoded', () => {\n expect(readInvitationToken(`${PAGE}#invite%3D${TOKEN}`).token).toEqual(TOKEN)\n })\n\n it('hands back the address without the fragment', () => {\n /* ⚠ What the caller replaces the address with. Without it the token stays on screen and in\n this history entry, which is the half of the hiding the fragment does not do by itself. */\n expect(readInvitationToken(`${PAGE}?x=1#${TOKEN}`).clean).toEqual(`${PAGE}?x=1`)\n })\n})\n\ndescribe('a link with nothing usable in it', () => {\n it('has no fragment at all', () => {\n expect(readInvitationToken(PAGE)).toEqual({ token: '', clean: PAGE })\n })\n\n it('has an empty fragment', () => {\n expect(readInvitationToken(`${PAGE}#`)).toEqual({ token: '', clean: PAGE })\n })\n\n it('⚠ carries something that is not a token', () => {\n /*\n * ⚠ A page anchor, a tracking fragment, anything a link happened to pick up. Sending it as a\n * credential attempt would be this page volunteering whatever it was given.\n */\n expect(readInvitationToken(`${PAGE}#section-2`).token).toEqual('')\n expect(readInvitationToken(`${PAGE}#utm_source=mail`).token).toEqual('')\n expect(readInvitationToken(`${PAGE}#<script>`).token).toEqual('')\n })\n\n it('carries something token-shaped but the wrong length', () => {\n expect(readInvitationToken(`${PAGE}#0123456789abcdef`).token).toEqual('')\n expect(readInvitationToken(`${PAGE}#${TOKEN}ff`).token).toEqual('')\n })\n\n it('carries hex in the wrong case', () => {\n /* `makeInvitationToken` produces lower-case hex. Anything else did not come from us. */\n expect(readInvitationToken(`${PAGE}#${TOKEN.toUpperCase()}`).token).toEqual('')\n })\n\n it('still hands back a clean address to replace with', () => {\n /* Even a fragment that is not ours should not be left sitting in the bar for this page. */\n expect(readInvitationToken(`${PAGE}#section-2`).clean).toEqual(PAGE)\n })\n\n it('is given nothing at all', () => {\n expect(readInvitationToken('')).toEqual({ token: '', clean: '' })\n expect(readInvitationToken(undefined as any)).toEqual({ token: '', clean: '' })\n })\n})\n"]}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * When a list of roles has to say where each one came from. (Pure rule.)
3
+ *
4
+ * ## The situation it is for
5
+ *
6
+ * Roles are visible one level up: a domain sees its own and its parent's. Nothing stops a
7
+ * domain having a role with the same name as one it inherits — the duplicate checks on the
8
+ * server ask only about the domain itself — so a picker can offer two rows reading `approver`
9
+ * with no way to tell them apart. Whichever gets ticked decides who a gate lets through, and
10
+ * an approval line can end up pointing at the one nobody looks at (ADR-0062 decision 3).
11
+ *
12
+ * ## Why the rule is about the list and not about one role
13
+ *
14
+ * A role does not know whether it is inherited — that depends on which domain is being looked
15
+ * at, and a list of roles from one domain is the ordinary case. So the question is asked of the
16
+ * list: **does this set span more than one domain?** If it does, every row says which, and the
17
+ * rows that shared a name stop being identical. If it does not, nothing is added and the
18
+ * ordinary case stays quiet.
19
+ *
20
+ * That also answers correctly for a role whose name happens to be unique but which still comes
21
+ * from somewhere else — granting one of those is a different act from granting your own, and
22
+ * the operator should be able to see which they are doing.
23
+ */
24
+ /** The part of a role this rule reads. `domain` is absent on lists that never asked for it. */
25
+ export type RoleWithOrigin = {
26
+ domain?: {
27
+ name?: string | null;
28
+ } | null;
29
+ } | null | undefined;
30
+ /**
31
+ * Should this list show where each role comes from?
32
+ *
33
+ * False when every role names the same domain, when none of them names one at all, and for an
34
+ * empty list. A list that is only partly labelled counts as spanning — one row that cannot say
35
+ * where it came from, beside rows that can, is the ambiguity this exists to remove.
36
+ */
37
+ export declare function showsRoleOrigin(roles: RoleWithOrigin[] | null | undefined): boolean;
38
+ /**
39
+ * What to print beside a role, or an empty string.
40
+ *
41
+ * A role with no domain on it prints nothing rather than a placeholder: the list is already
42
+ * labelled, so an unlabelled row reads as "not said" instead of as a domain called something.
43
+ */
44
+ export declare function roleOrigin(role: RoleWithOrigin): string;