@things-factory/auth-ui 10.1.16 → 10.1.18

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 (98) hide show
  1. package/dist-client/components/app-binding-detail.d.ts +46 -0
  2. package/dist-client/components/app-binding-detail.js +384 -0
  3. package/dist-client/components/app-binding-detail.js.map +1 -0
  4. package/dist-client/components/appliance-editor.d.ts +37 -0
  5. package/dist-client/components/appliance-editor.js +489 -0
  6. package/dist-client/components/appliance-editor.js.map +1 -0
  7. package/dist-client/components/application-editor.d.ts +31 -0
  8. package/dist-client/components/application-editor.js +491 -0
  9. package/dist-client/components/application-editor.js.map +1 -0
  10. package/dist-client/components/profile-component.js +28 -11
  11. package/dist-client/components/profile-component.js.map +1 -1
  12. package/dist-client/components/secret-field.d.ts +35 -0
  13. package/dist-client/components/secret-field.js +149 -0
  14. package/dist-client/components/secret-field.js.map +1 -0
  15. package/dist-client/constants/application-type-parity.test.js +62 -0
  16. package/dist-client/constants/application-type-parity.test.js.map +1 -0
  17. package/dist-client/constants/application.d.ts +17 -7
  18. package/dist-client/constants/application.js +18 -8
  19. package/dist-client/constants/application.js.map +1 -1
  20. package/dist-client/pages/app-binding/app-binding-list-query.test.js +46 -0
  21. package/dist-client/pages/app-binding/app-binding-list-query.test.js.map +1 -0
  22. package/dist-client/pages/app-binding/app-bindings.d.ts +65 -2
  23. package/dist-client/pages/app-binding/app-bindings.js +217 -117
  24. package/dist-client/pages/app-binding/app-bindings.js.map +1 -1
  25. package/dist-client/pages/appliance/appliance-list-query.test.d.ts +1 -0
  26. package/dist-client/pages/appliance/appliance-list-query.test.js +58 -0
  27. package/dist-client/pages/appliance/appliance-list-query.test.js.map +1 -0
  28. package/dist-client/pages/appliance/home.d.ts +63 -2
  29. package/dist-client/pages/appliance/home.js +219 -123
  30. package/dist-client/pages/appliance/home.js.map +1 -1
  31. package/dist-client/pages/application/applications.d.ts +63 -2
  32. package/dist-client/pages/application/applications.js +212 -129
  33. package/dist-client/pages/application/applications.js.map +1 -1
  34. package/dist-client/pages/auth-provider/auth-provider-management.d.ts +4 -2
  35. package/dist-client/pages/auth-provider/auth-provider-management.js +35 -6
  36. package/dist-client/pages/auth-provider/auth-provider-management.js.map +1 -1
  37. package/dist-client/pages/domain-owner/domain-owner-management.d.ts +2 -0
  38. package/dist-client/pages/domain-owner/domain-owner-management.js +34 -7
  39. package/dist-client/pages/domain-owner/domain-owner-management.js.map +1 -1
  40. package/dist-client/pages/secrets-are-not-printed.test.d.ts +1 -0
  41. package/dist-client/pages/secrets-are-not-printed.test.js +88 -0
  42. package/dist-client/pages/secrets-are-not-printed.test.js.map +1 -0
  43. package/dist-client/route.js +0 -15
  44. package/dist-client/route.js.map +1 -1
  45. package/dist-client/tsconfig.tsbuildinfo +1 -1
  46. package/dist-client/utils/application-status.d.ts +32 -0
  47. package/dist-client/utils/application-status.js +45 -0
  48. package/dist-client/utils/application-status.js.map +1 -0
  49. package/dist-client/utils/application-status.test.d.ts +1 -0
  50. package/dist-client/utils/application-status.test.js +45 -0
  51. package/dist-client/utils/application-status.test.js.map +1 -0
  52. package/dist-client/utils/form-values.d.ts +71 -0
  53. package/dist-client/utils/form-values.js +102 -0
  54. package/dist-client/utils/form-values.js.map +1 -0
  55. package/dist-client/utils/form-values.test.d.ts +1 -0
  56. package/dist-client/utils/form-values.test.js +120 -0
  57. package/dist-client/utils/form-values.test.js.map +1 -0
  58. package/dist-client/utils/list-result.d.ts +62 -0
  59. package/dist-client/utils/list-result.js +92 -0
  60. package/dist-client/utils/list-result.js.map +1 -0
  61. package/dist-client/utils/list-result.test.d.ts +1 -0
  62. package/dist-client/utils/list-result.test.js +160 -0
  63. package/dist-client/utils/list-result.test.js.map +1 -0
  64. package/dist-client/utils/secret-display.d.ts +51 -0
  65. package/dist-client/utils/secret-display.js +61 -0
  66. package/dist-client/utils/secret-display.js.map +1 -0
  67. package/dist-client/utils/secret-display.test.d.ts +1 -0
  68. package/dist-client/utils/secret-display.test.js +59 -0
  69. package/dist-client/utils/secret-display.test.js.map +1 -0
  70. package/dist-client/utils/webauthn-verification.d.ts +56 -0
  71. package/dist-client/utils/webauthn-verification.js +79 -0
  72. package/dist-client/utils/webauthn-verification.js.map +1 -0
  73. package/dist-client/utils/webauthn-verification.test.d.ts +1 -0
  74. package/dist-client/utils/webauthn-verification.test.js +79 -0
  75. package/dist-client/utils/webauthn-verification.test.js.map +1 -0
  76. package/dist-server/tsconfig.tsbuildinfo +1 -1
  77. package/package.json +3 -3
  78. package/things-factory.config.js +0 -5
  79. package/translations/en.json +56 -0
  80. package/translations/ja.json +57 -0
  81. package/translations/ko.json +56 -0
  82. package/translations/ms.json +57 -0
  83. package/translations/zh.json +57 -0
  84. package/dist-client/pages/app-binding/app-binding.d.ts +0 -1
  85. package/dist-client/pages/app-binding/app-binding.js +0 -371
  86. package/dist-client/pages/app-binding/app-binding.js.map +0 -1
  87. package/dist-client/pages/appliance/appliance.d.ts +0 -1
  88. package/dist-client/pages/appliance/appliance.js +0 -392
  89. package/dist-client/pages/appliance/appliance.js.map +0 -1
  90. package/dist-client/pages/appliance/register.js +0 -166
  91. package/dist-client/pages/appliance/register.js.map +0 -1
  92. package/dist-client/pages/application/application.d.ts +0 -1
  93. package/dist-client/pages/application/application.js +0 -377
  94. package/dist-client/pages/application/application.js.map +0 -1
  95. package/dist-client/pages/application/register.js +0 -192
  96. package/dist-client/pages/application/register.js.map +0 -1
  97. /package/dist-client/{pages/appliance/register.d.ts → constants/application-type-parity.test.d.ts} +0 -0
  98. /package/dist-client/pages/{application/register.d.ts → app-binding/app-binding-list-query.test.d.ts} +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"list-result.js","sourceRoot":"","sources":["../../client/utils/list-result.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAaH;;;;;;;;;;;;;GAaG;AACH,SAAS,cAAc,CAAC,QAAa,EAAE,KAAa;IAClD,MAAM,MAAM,GAAG,QAAQ,EAAE,MAAM,CAAA;IAE/B,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;QAC9B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,KAAU,EAAE,EAAE;QAChC,MAAM,IAAI,GAAG,KAAK,EAAE,IAAI,CAAA;QAExB,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC9C,OAAO,IAAI,CAAA;QACb,CAAC;QAED,uFAAuF;QACvF,OAAO,IAAI,CAAC,CAAC,CAAC,KAAK,KAAK,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,CAAA;IAC/C,CAAC,CAAC,CAAA;AACJ,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,gBAAgB,CAAU,QAAa,EAAE,KAAa;IACpE,IAAI,CAAC,QAAQ,IAAI,cAAc,CAAC,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC;QACjD,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAA;IAEnC,OAAO,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAA;AAC7D,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,iBAAiB,CAAU,QAAa,EAAE,KAAa;IACrE,IAAI,CAAC,QAAQ,IAAI,cAAc,CAAC,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC;QACjD,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAA;IAEnC,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,SAAS,CAAA;AAC9E,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAU,IAAoC;IACvE,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAA;IAC9C,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,CAAA;AACrF,CAAC","sourcesContent":["/**\n * Turning a list query's response into \"what the screen may claim\".\n *\n * ── Why this is not inline in each page ──────────────────────────────────────\n * Three auth screens had the same two lines each, and all three were wrong the same way:\n *\n * this.items = (await this.fetch()).items\n * …\n * if (!response.errors) { return response.data.applications }\n *\n * The shared client runs queries with `errorPolicy: 'all'`, so a GraphQL error comes back\n * **in the response** instead of throwing. The helper then returned nothing, `.items` threw\n * inside an async lifecycle method with nobody to catch it, and the table stayed empty. A\n * failure and an empty list looked identical to the person reading the screen.\n *\n * The rule is small enough to get wrong in three places and small enough to test once.\n */\n\n/** What a list query returns when it answers. */\nexport type ListPage<T = any> = { items: T[]; total?: number }\n\n/**\n * What a screen is entitled to say afterwards.\n *\n * `failed` is not \"empty\". A screen that could not ask has to say so; saying nothing reads\n * as \"there are none\", which is a claim it has no basis for.\n */\nexport type ListOutcome<T = any> = { items: T[]; total: number; failed: boolean }\n\n/**\n * Did an error stop the list, or only withhold a field inside it?\n *\n * GraphQL says where an error happened. A gate on a whole query rejects at the root, so the\n * path is the query field itself or nothing at all. A gate on one **column** — and this house\n * has them, `Appliance.accessToken` and `AuthProvider.clientSecret` both carry their own\n * `@privilege` — rejects deeper in, at `[field, 'items', 3, 'clientSecret']`.\n *\n * The difference decides what the screen may show. Treating both as failure blacks out a list\n * that arrived intact apart from one column a viewer may not read; treating both as success\n * renders a refused query as an empty list. Neither is acceptable, so the path is read.\n *\n * An error with no path at all is counted as fatal: unknown scope leans to the safe side.\n */\nfunction stoppedTheList(response: any, field: string): boolean {\n const errors = response?.errors\n\n if (!errors || !errors.length) {\n return false\n }\n\n return errors.some((error: any) => {\n const path = error?.path\n\n if (!Array.isArray(path) || path.length === 0) {\n return true\n }\n\n /* `[field]` alone is the query itself being refused; deeper is one value inside it. */\n return path[0] === field && path.length === 1\n })\n}\n\n/**\n * Read a list page out of an Apollo response, or nothing if the list did not arrive.\n *\n * Ways not to arrive:\n * · an error at the query itself (errorPolicy 'all' puts errors here rather than throwing)\n * · `data` missing entirely\n * · `data` present but the field we asked for is not, or is not a page\n *\n * An error *inside* the page is not one of them — see `stoppedTheList`.\n */\nexport function pageFromResponse<T = any>(response: any, field: string): ListPage<T> | undefined {\n if (!response || stoppedTheList(response, field)) {\n return undefined\n }\n\n const page = response.data?.[field]\n\n return page && Array.isArray(page.items) ? page : undefined\n}\n\n/**\n * The same question for a query that returns a bare array rather than a page.\n *\n * `domainOwners` is one: it answers with a list and no total. The screen was reading it as\n * `response.data?.domainOwners || []`, which turns a refused query into \"this domain has no\n * owners\" — a sentence with real consequences, since the reader may then go and add one.\n *\n * Returns nothing on failure for the same reason as `pageFromResponse`, so `listOutcome`\n * can tell the two apart.\n */\nexport function arrayFromResponse<T = any>(response: any, field: string): ListPage<T> | undefined {\n if (!response || stoppedTheList(response, field)) {\n return undefined\n }\n\n const rows = response.data?.[field]\n\n return Array.isArray(rows) ? { items: rows, total: rows.length } : undefined\n}\n\n/**\n * What the screen shows, given a page or the absence of one.\n *\n * An absent page yields an empty list **and** `failed`, so the caller cannot accidentally\n * render \"none\" — the flag is right there next to the rows.\n */\nexport function listOutcome<T = any>(page: ListPage<T> | undefined | null): ListOutcome<T> {\n if (!page) {\n return { items: [], total: 0, failed: true }\n }\n\n return { items: page.items, total: page.total ?? page.items.length, failed: false }\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,160 @@
1
+ import { arrayFromResponse, listOutcome, pageFromResponse } from './list-result';
2
+ /**
3
+ * A list screen may only claim what the server actually answered.
4
+ *
5
+ * The three auth list screens each carried this rule inline and each got it wrong the same
6
+ * way: a failed query produced an empty table with nothing said. These cases are the ones
7
+ * that were failing in production, written down so they stay fixed.
8
+ */
9
+ describe('reading a list page out of a response', () => {
10
+ it('reads the page when the query answered', () => {
11
+ const response = { data: { applications: { items: [{ id: 'a' }], total: 1 } } };
12
+ expect(pageFromResponse(response, 'applications')).toEqual({ items: [{ id: 'a' }], total: 1 });
13
+ });
14
+ it('refuses a response carrying errors', () => {
15
+ /*
16
+ * The shared client runs with errorPolicy 'all', so errors arrive here instead of
17
+ * throwing. This is the case that used to slip through.
18
+ */
19
+ const response = { errors: [{ message: 'user:query 권한이 필요합니다' }], data: null };
20
+ expect(pageFromResponse(response, 'applications')).toBeUndefined();
21
+ });
22
+ it('refuses a response that carries errors even when the page looks complete', () => {
23
+ /*
24
+ * ⚠ The case that makes the error check load-bearing.
25
+ *
26
+ * errorPolicy 'all' can return `data` **and** `errors` together — a list that came back
27
+ * while some field resolver failed. Every other case here is caught by the data checks,
28
+ * so without this one the error branch could be deleted and the suite stayed green. It
29
+ * was: removing it changed nothing until this test existed.
30
+ *
31
+ * Refusing is the lean. A partial list rendered as a whole one is a claim the screen
32
+ * cannot support, and the person reading it has no way to tell which rows are missing.
33
+ */
34
+ const response = {
35
+ errors: [{ message: 'privileges field failed' }],
36
+ data: { applications: { items: [{ id: 'a' }], total: 1 } }
37
+ };
38
+ expect(pageFromResponse(response, 'applications')).toBeUndefined();
39
+ });
40
+ it('refuses a partial response where the field is null but errors explain why', () => {
41
+ /*
42
+ * The quiet one. `data` exists, so a check that only asks "is data there" passes, and
43
+ * the next line reads `.items` off null.
44
+ */
45
+ const response = { errors: [{ message: 'forbidden' }], data: { applications: null } };
46
+ expect(pageFromResponse(response, 'applications')).toBeUndefined();
47
+ });
48
+ it('refuses a response with no errors but no field either', () => {
49
+ expect(pageFromResponse({ data: {} }, 'applications')).toBeUndefined();
50
+ expect(pageFromResponse({ data: { applications: null } }, 'applications')).toBeUndefined();
51
+ });
52
+ it('refuses a field that is not a page', () => {
53
+ /* `items` missing means we cannot list it, whatever else came back. */
54
+ expect(pageFromResponse({ data: { applications: { total: 3 } } }, 'applications')).toBeUndefined();
55
+ });
56
+ it('refuses nothing at all', () => {
57
+ expect(pageFromResponse(undefined, 'applications')).toBeUndefined();
58
+ expect(pageFromResponse(null, 'applications')).toBeUndefined();
59
+ });
60
+ });
61
+ describe('telling a refused query from a withheld column', () => {
62
+ /*
63
+ * This house gates individual columns: `Appliance.accessToken` and
64
+ * `AuthProvider.clientSecret` each carry their own @privilege. A viewer without it gets
65
+ * the whole list plus one error pointing at that value.
66
+ *
67
+ * Reading the error's path is what separates the two. Getting it wrong is visible either
68
+ * way: black out a list that arrived, or show a refused query as empty.
69
+ */
70
+ it('keeps the page when only a value inside it was refused', () => {
71
+ const response = {
72
+ errors: [{ message: 'security:query 권한이 필요합니다', path: ['authProviders', 'items', 2, 'clientSecret'] }],
73
+ data: { authProviders: { items: [{ id: 'a', clientSecret: null }], total: 1 } }
74
+ };
75
+ expect(pageFromResponse(response, 'authProviders')).toEqual({
76
+ items: [{ id: 'a', clientSecret: null }],
77
+ total: 1
78
+ });
79
+ });
80
+ it('refuses when the query itself was the thing rejected', () => {
81
+ const response = {
82
+ errors: [{ message: 'user:query 권한이 필요합니다', path: ['authProviders'] }],
83
+ data: { authProviders: null }
84
+ };
85
+ expect(pageFromResponse(response, 'authProviders')).toBeUndefined();
86
+ });
87
+ it('refuses an error that does not say where it happened', () => {
88
+ /* Unknown scope leans to the safe side: a network or parse failure stops the list. */
89
+ const response = {
90
+ errors: [{ message: 'Failed to fetch' }],
91
+ data: { authProviders: { items: [{ id: 'a' }], total: 1 } }
92
+ };
93
+ expect(pageFromResponse(response, 'authProviders')).toBeUndefined();
94
+ });
95
+ it('refuses when any one error is fatal, even beside a harmless one', () => {
96
+ const response = {
97
+ errors: [
98
+ { message: 'withheld', path: ['authProviders', 'items', 0, 'clientSecret'] },
99
+ { message: 'refused', path: ['authProviders'] }
100
+ ],
101
+ data: { authProviders: { items: [], total: 0 } }
102
+ };
103
+ expect(pageFromResponse(response, 'authProviders')).toBeUndefined();
104
+ });
105
+ it('applies the same reading to a bare array', () => {
106
+ const response = {
107
+ errors: [{ message: 'withheld', path: ['domainOwners', 0, 'reason'] }],
108
+ data: { domainOwners: [{ id: 'a', reason: null }] }
109
+ };
110
+ expect(arrayFromResponse(response, 'domainOwners')).toEqual({ items: [{ id: 'a', reason: null }], total: 1 });
111
+ });
112
+ });
113
+ describe('a query that answers with a bare array', () => {
114
+ it('reads the rows and counts them', () => {
115
+ const response = { data: { domainOwners: [{ id: 'a' }, { id: 'b' }] } };
116
+ expect(arrayFromResponse(response, 'domainOwners')).toEqual({ items: [{ id: 'a' }, { id: 'b' }], total: 2 });
117
+ });
118
+ it('keeps an answer of none as an answer', () => {
119
+ expect(arrayFromResponse({ data: { domainOwners: [] } }, 'domainOwners')).toEqual({ items: [], total: 0 });
120
+ });
121
+ it('refuses a response carrying errors even when rows came back', () => {
122
+ /*
123
+ * ⚠ The load-bearing case, same as for pages. The screen this replaces wrote
124
+ * `response.data?.domainOwners || []`, so a refused query read as "this domain has no
125
+ * owners" — a sentence an administrator may act on by adding one.
126
+ */
127
+ const response = { errors: [{ message: 'forbidden' }], data: { domainOwners: [{ id: 'a' }] } };
128
+ expect(arrayFromResponse(response, 'domainOwners')).toBeUndefined();
129
+ });
130
+ it('refuses a missing or non-array field', () => {
131
+ expect(arrayFromResponse({ data: {} }, 'domainOwners')).toBeUndefined();
132
+ expect(arrayFromResponse({ data: { domainOwners: null } }, 'domainOwners')).toBeUndefined();
133
+ expect(arrayFromResponse({ data: { domainOwners: { items: [] } } }, 'domainOwners')).toBeUndefined();
134
+ });
135
+ });
136
+ describe('what the screen may then say', () => {
137
+ it('shows the rows when there are rows', () => {
138
+ expect(listOutcome({ items: [{ id: 'a' }, { id: 'b' }], total: 2 })).toEqual({
139
+ items: [{ id: 'a' }, { id: 'b' }],
140
+ total: 2,
141
+ failed: false
142
+ });
143
+ });
144
+ it('an answer of none is not a failure', () => {
145
+ /* The distinction this whole file exists for, from the other side. */
146
+ expect(listOutcome({ items: [], total: 0 })).toEqual({ items: [], total: 0, failed: false });
147
+ });
148
+ it('no answer is a failure, and the rows are empty without claiming emptiness', () => {
149
+ expect(listOutcome(undefined)).toEqual({ items: [], total: 0, failed: true });
150
+ expect(listOutcome(null)).toEqual({ items: [], total: 0, failed: true });
151
+ });
152
+ it('counts the rows when the server sent no total', () => {
153
+ expect(listOutcome({ items: [{ id: 'a' }] }).total).toBe(1);
154
+ });
155
+ it('keeps a total of zero rather than recounting it', () => {
156
+ /* `?? ` and not `||` — a real zero must survive, or an empty list reports its length. */
157
+ expect(listOutcome({ items: [], total: 0 }).total).toBe(0);
158
+ });
159
+ });
160
+ //# sourceMappingURL=list-result.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"list-result.test.js","sourceRoot":"","sources":["../../client/utils/list-result.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { arrayFromResponse, listOutcome, pageFromResponse } from './list-result'\n\n/**\n * A list screen may only claim what the server actually answered.\n *\n * The three auth list screens each carried this rule inline and each got it wrong the same\n * way: a failed query produced an empty table with nothing said. These cases are the ones\n * that were failing in production, written down so they stay fixed.\n */\n\ndescribe('reading a list page out of a response', () => {\n it('reads the page when the query answered', () => {\n const response = { data: { applications: { items: [{ id: 'a' }], total: 1 } } }\n\n expect(pageFromResponse(response, 'applications')).toEqual({ items: [{ id: 'a' }], total: 1 })\n })\n\n it('refuses a response carrying errors', () => {\n /*\n * The shared client runs with errorPolicy 'all', so errors arrive here instead of\n * throwing. This is the case that used to slip through.\n */\n const response = { errors: [{ message: 'user:query 권한이 필요합니다' }], data: null }\n\n expect(pageFromResponse(response, 'applications')).toBeUndefined()\n })\n\n it('refuses a response that carries errors even when the page looks complete', () => {\n /*\n * ⚠ The case that makes the error check load-bearing.\n *\n * errorPolicy 'all' can return `data` **and** `errors` together — a list that came back\n * while some field resolver failed. Every other case here is caught by the data checks,\n * so without this one the error branch could be deleted and the suite stayed green. It\n * was: removing it changed nothing until this test existed.\n *\n * Refusing is the lean. A partial list rendered as a whole one is a claim the screen\n * cannot support, and the person reading it has no way to tell which rows are missing.\n */\n const response = {\n errors: [{ message: 'privileges field failed' }],\n data: { applications: { items: [{ id: 'a' }], total: 1 } }\n }\n\n expect(pageFromResponse(response, 'applications')).toBeUndefined()\n })\n\n it('refuses a partial response where the field is null but errors explain why', () => {\n /*\n * The quiet one. `data` exists, so a check that only asks \"is data there\" passes, and\n * the next line reads `.items` off null.\n */\n const response = { errors: [{ message: 'forbidden' }], data: { applications: null } }\n\n expect(pageFromResponse(response, 'applications')).toBeUndefined()\n })\n\n it('refuses a response with no errors but no field either', () => {\n expect(pageFromResponse({ data: {} }, 'applications')).toBeUndefined()\n expect(pageFromResponse({ data: { applications: null } }, 'applications')).toBeUndefined()\n })\n\n it('refuses a field that is not a page', () => {\n /* `items` missing means we cannot list it, whatever else came back. */\n expect(pageFromResponse({ data: { applications: { total: 3 } } }, 'applications')).toBeUndefined()\n })\n\n it('refuses nothing at all', () => {\n expect(pageFromResponse(undefined, 'applications')).toBeUndefined()\n expect(pageFromResponse(null, 'applications')).toBeUndefined()\n })\n})\n\ndescribe('telling a refused query from a withheld column', () => {\n /*\n * This house gates individual columns: `Appliance.accessToken` and\n * `AuthProvider.clientSecret` each carry their own @privilege. A viewer without it gets\n * the whole list plus one error pointing at that value.\n *\n * Reading the error's path is what separates the two. Getting it wrong is visible either\n * way: black out a list that arrived, or show a refused query as empty.\n */\n it('keeps the page when only a value inside it was refused', () => {\n const response = {\n errors: [{ message: 'security:query 권한이 필요합니다', path: ['authProviders', 'items', 2, 'clientSecret'] }],\n data: { authProviders: { items: [{ id: 'a', clientSecret: null }], total: 1 } }\n }\n\n expect(pageFromResponse(response, 'authProviders')).toEqual({\n items: [{ id: 'a', clientSecret: null }],\n total: 1\n })\n })\n\n it('refuses when the query itself was the thing rejected', () => {\n const response = {\n errors: [{ message: 'user:query 권한이 필요합니다', path: ['authProviders'] }],\n data: { authProviders: null }\n }\n\n expect(pageFromResponse(response, 'authProviders')).toBeUndefined()\n })\n\n it('refuses an error that does not say where it happened', () => {\n /* Unknown scope leans to the safe side: a network or parse failure stops the list. */\n const response = {\n errors: [{ message: 'Failed to fetch' }],\n data: { authProviders: { items: [{ id: 'a' }], total: 1 } }\n }\n\n expect(pageFromResponse(response, 'authProviders')).toBeUndefined()\n })\n\n it('refuses when any one error is fatal, even beside a harmless one', () => {\n const response = {\n errors: [\n { message: 'withheld', path: ['authProviders', 'items', 0, 'clientSecret'] },\n { message: 'refused', path: ['authProviders'] }\n ],\n data: { authProviders: { items: [], total: 0 } }\n }\n\n expect(pageFromResponse(response, 'authProviders')).toBeUndefined()\n })\n\n it('applies the same reading to a bare array', () => {\n const response = {\n errors: [{ message: 'withheld', path: ['domainOwners', 0, 'reason'] }],\n data: { domainOwners: [{ id: 'a', reason: null }] }\n }\n\n expect(arrayFromResponse(response, 'domainOwners')).toEqual({ items: [{ id: 'a', reason: null }], total: 1 })\n })\n})\n\ndescribe('a query that answers with a bare array', () => {\n it('reads the rows and counts them', () => {\n const response = { data: { domainOwners: [{ id: 'a' }, { id: 'b' }] } }\n\n expect(arrayFromResponse(response, 'domainOwners')).toEqual({ items: [{ id: 'a' }, { id: 'b' }], total: 2 })\n })\n\n it('keeps an answer of none as an answer', () => {\n expect(arrayFromResponse({ data: { domainOwners: [] } }, 'domainOwners')).toEqual({ items: [], total: 0 })\n })\n\n it('refuses a response carrying errors even when rows came back', () => {\n /*\n * ⚠ The load-bearing case, same as for pages. The screen this replaces wrote\n * `response.data?.domainOwners || []`, so a refused query read as \"this domain has no\n * owners\" — a sentence an administrator may act on by adding one.\n */\n const response = { errors: [{ message: 'forbidden' }], data: { domainOwners: [{ id: 'a' }] } }\n\n expect(arrayFromResponse(response, 'domainOwners')).toBeUndefined()\n })\n\n it('refuses a missing or non-array field', () => {\n expect(arrayFromResponse({ data: {} }, 'domainOwners')).toBeUndefined()\n expect(arrayFromResponse({ data: { domainOwners: null } }, 'domainOwners')).toBeUndefined()\n expect(arrayFromResponse({ data: { domainOwners: { items: [] } } }, 'domainOwners')).toBeUndefined()\n })\n})\n\ndescribe('what the screen may then say', () => {\n it('shows the rows when there are rows', () => {\n expect(listOutcome({ items: [{ id: 'a' }, { id: 'b' }], total: 2 })).toEqual({\n items: [{ id: 'a' }, { id: 'b' }],\n total: 2,\n failed: false\n })\n })\n\n it('an answer of none is not a failure', () => {\n /* The distinction this whole file exists for, from the other side. */\n expect(listOutcome({ items: [], total: 0 })).toEqual({ items: [], total: 0, failed: false })\n })\n\n it('no answer is a failure, and the rows are empty without claiming emptiness', () => {\n expect(listOutcome(undefined)).toEqual({ items: [], total: 0, failed: true })\n expect(listOutcome(null)).toEqual({ items: [], total: 0, failed: true })\n })\n\n it('counts the rows when the server sent no total', () => {\n expect(listOutcome({ items: [{ id: 'a' }] }).total).toBe(1)\n })\n\n it('keeps a total of zero rather than recounting it', () => {\n /* `?? ` and not `||` — a real zero must survive, or an empty list reports its length. */\n expect(listOutcome({ items: [], total: 0 }).total).toBe(0)\n })\n})\n"]}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Showing a credential on screen.
3
+ *
4
+ * ── The screens this is for ──────────────────────────────────────────────────
5
+ * An application's refresh token, an appliance's access token, a provider's client secret.
6
+ * They have one thing in common that ordinary fields do not: the reader is meant to **copy**
7
+ * one, once, and never read it again. Every other moment the screen is open, the value on it
8
+ * is a liability — on a shared display, in a screenshot, over a shoulder.
9
+ *
10
+ * Today they are `<input readonly>` with the value in plain text, shown the moment the page
11
+ * loads.
12
+ *
13
+ * ── The rule that has teeth: the mask must not leak the length ───────────────
14
+ * The obvious mask is one dot per character, and it is wrong. A token's length says which
15
+ * kind of token it is, and for a short secret it narrows the guess badly — a six-character
16
+ * mask beside a twenty-character one tells you which is worth attacking. A mask is supposed
17
+ * to say "there is a value here", nothing else, so it is a fixed width.
18
+ *
19
+ * The one thing a mask must distinguish is **present** from **absent**. A field with no
20
+ * secret shows nothing at all: dots over an empty value would have the reader hunting for a
21
+ * token that was never issued.
22
+ */
23
+ /**
24
+ * How wide a mask is, whatever it hides.
25
+ *
26
+ * Sixteen, to match `OxGristRendererSecret` in @operato/data-grist. The platform reached the
27
+ * same two rules on its own — a fixed width, and nothing at all for an absent value — and two
28
+ * different mask widths on adjacent screens would read as two different kinds of secret.
29
+ */
30
+ export declare const MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
31
+ /**
32
+ * What to show when the value is hidden.
33
+ *
34
+ * Empty for an absent secret — see above; the distinction between "hidden" and "not issued"
35
+ * is the one thing the mask is allowed to carry.
36
+ */
37
+ export declare function maskedSecret(value: string | null | undefined): string;
38
+ /**
39
+ * What to show, given whether the reader has asked to see it.
40
+ *
41
+ * Revealing is an action the reader takes, so it is never the initial state — see
42
+ * `secret-display.test.ts` for the case that pins it.
43
+ */
44
+ export declare function shownSecret(value: string | null | undefined, revealed: boolean): string;
45
+ /**
46
+ * Is there anything to copy or reveal?
47
+ *
48
+ * Buttons that do nothing are worse than absent ones: the reader presses, nothing happens,
49
+ * and they cannot tell whether the screen is broken or the token is missing.
50
+ */
51
+ export declare function hasSecret(value: string | null | undefined): boolean;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Showing a credential on screen.
3
+ *
4
+ * ── The screens this is for ──────────────────────────────────────────────────
5
+ * An application's refresh token, an appliance's access token, a provider's client secret.
6
+ * They have one thing in common that ordinary fields do not: the reader is meant to **copy**
7
+ * one, once, and never read it again. Every other moment the screen is open, the value on it
8
+ * is a liability — on a shared display, in a screenshot, over a shoulder.
9
+ *
10
+ * Today they are `<input readonly>` with the value in plain text, shown the moment the page
11
+ * loads.
12
+ *
13
+ * ── The rule that has teeth: the mask must not leak the length ───────────────
14
+ * The obvious mask is one dot per character, and it is wrong. A token's length says which
15
+ * kind of token it is, and for a short secret it narrows the guess badly — a six-character
16
+ * mask beside a twenty-character one tells you which is worth attacking. A mask is supposed
17
+ * to say "there is a value here", nothing else, so it is a fixed width.
18
+ *
19
+ * The one thing a mask must distinguish is **present** from **absent**. A field with no
20
+ * secret shows nothing at all: dots over an empty value would have the reader hunting for a
21
+ * token that was never issued.
22
+ */
23
+ /**
24
+ * How wide a mask is, whatever it hides.
25
+ *
26
+ * Sixteen, to match `OxGristRendererSecret` in @operato/data-grist. The platform reached the
27
+ * same two rules on its own — a fixed width, and nothing at all for an absent value — and two
28
+ * different mask widths on adjacent screens would read as two different kinds of secret.
29
+ */
30
+ export const MASK = '••••••••••••••••';
31
+ /**
32
+ * What to show when the value is hidden.
33
+ *
34
+ * Empty for an absent secret — see above; the distinction between "hidden" and "not issued"
35
+ * is the one thing the mask is allowed to carry.
36
+ */
37
+ export function maskedSecret(value) {
38
+ return value ? MASK : '';
39
+ }
40
+ /**
41
+ * What to show, given whether the reader has asked to see it.
42
+ *
43
+ * Revealing is an action the reader takes, so it is never the initial state — see
44
+ * `secret-display.test.ts` for the case that pins it.
45
+ */
46
+ export function shownSecret(value, revealed) {
47
+ if (!value) {
48
+ return '';
49
+ }
50
+ return revealed ? value : maskedSecret(value);
51
+ }
52
+ /**
53
+ * Is there anything to copy or reveal?
54
+ *
55
+ * Buttons that do nothing are worse than absent ones: the reader presses, nothing happens,
56
+ * and they cannot tell whether the screen is broken or the token is missing.
57
+ */
58
+ export function hasSecret(value) {
59
+ return !!value;
60
+ }
61
+ //# sourceMappingURL=secret-display.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-display.js","sourceRoot":"","sources":["../../client/utils/secret-display.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,kBAAkB,CAAA;AAEtC;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,KAAgC;IAC3D,OAAO,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAA;AAC1B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAgC,EAAE,QAAiB;IAC7E,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,EAAE,CAAA;IACX,CAAC;IAED,OAAO,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,CAAA;AAC/C,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,KAAgC;IACxD,OAAO,CAAC,CAAC,KAAK,CAAA;AAChB,CAAC","sourcesContent":["/**\n * Showing a credential on screen.\n *\n * ── The screens this is for ──────────────────────────────────────────────────\n * An application's refresh token, an appliance's access token, a provider's client secret.\n * They have one thing in common that ordinary fields do not: the reader is meant to **copy**\n * one, once, and never read it again. Every other moment the screen is open, the value on it\n * is a liability — on a shared display, in a screenshot, over a shoulder.\n *\n * Today they are `<input readonly>` with the value in plain text, shown the moment the page\n * loads.\n *\n * ── The rule that has teeth: the mask must not leak the length ───────────────\n * The obvious mask is one dot per character, and it is wrong. A token's length says which\n * kind of token it is, and for a short secret it narrows the guess badly — a six-character\n * mask beside a twenty-character one tells you which is worth attacking. A mask is supposed\n * to say \"there is a value here\", nothing else, so it is a fixed width.\n *\n * The one thing a mask must distinguish is **present** from **absent**. A field with no\n * secret shows nothing at all: dots over an empty value would have the reader hunting for a\n * token that was never issued.\n */\n\n/**\n * How wide a mask is, whatever it hides.\n *\n * Sixteen, to match `OxGristRendererSecret` in @operato/data-grist. The platform reached the\n * same two rules on its own — a fixed width, and nothing at all for an absent value — and two\n * different mask widths on adjacent screens would read as two different kinds of secret.\n */\nexport const MASK = '••••••••••••••••'\n\n/**\n * What to show when the value is hidden.\n *\n * Empty for an absent secret — see above; the distinction between \"hidden\" and \"not issued\"\n * is the one thing the mask is allowed to carry.\n */\nexport function maskedSecret(value: string | null | undefined): string {\n return value ? MASK : ''\n}\n\n/**\n * What to show, given whether the reader has asked to see it.\n *\n * Revealing is an action the reader takes, so it is never the initial state — see\n * `secret-display.test.ts` for the case that pins it.\n */\nexport function shownSecret(value: string | null | undefined, revealed: boolean): string {\n if (!value) {\n return ''\n }\n\n return revealed ? value : maskedSecret(value)\n}\n\n/**\n * Is there anything to copy or reveal?\n *\n * Buttons that do nothing are worse than absent ones: the reader presses, nothing happens,\n * and they cannot tell whether the screen is broken or the token is missing.\n */\nexport function hasSecret(value: string | null | undefined): boolean {\n return !!value\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,59 @@
1
+ import { hasSecret, MASK, maskedSecret, shownSecret } from './secret-display';
2
+ /**
3
+ * The app binding and appliance detail screens print a refresh token and an access token
4
+ * into a readonly input, in plain text, from the moment the page opens. These are the rules
5
+ * that replace that.
6
+ */
7
+ describe('the mask', () => {
8
+ it('is the same width whatever it hides', () => {
9
+ /*
10
+ * ⚠ The rule worth testing, because one-dot-per-character is the obvious mask and it
11
+ * leaks. A token's length says which kind of token it is; two masked fields of
12
+ * different widths tell a reader which is the short one.
13
+ */
14
+ const short = maskedSecret('abc');
15
+ const long = maskedSecret('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefg');
16
+ expect(short).toBe(long);
17
+ expect(short.length).not.toBe(3);
18
+ });
19
+ it('says a secret is there', () => {
20
+ expect(maskedSecret('anything')).toBe(MASK);
21
+ });
22
+ it('says nothing when there is no secret', () => {
23
+ /*
24
+ * Dots over an empty value send the reader hunting for a token nobody issued. The mask
25
+ * is allowed to carry exactly one bit — present or absent — and this is it.
26
+ */
27
+ expect(maskedSecret('')).toBe('');
28
+ expect(maskedSecret(null)).toBe('');
29
+ expect(maskedSecret(undefined)).toBe('');
30
+ });
31
+ });
32
+ describe('what the field shows', () => {
33
+ it('is masked until the reader asks', () => {
34
+ /* Revealing is an action. It is never where the screen starts. */
35
+ expect(shownSecret('a-real-token', false)).toBe(MASK);
36
+ });
37
+ it('is the value once they have asked', () => {
38
+ expect(shownSecret('a-real-token', true)).toBe('a-real-token');
39
+ });
40
+ it('stays empty when there is nothing, asked or not', () => {
41
+ expect(shownSecret('', true)).toBe('');
42
+ expect(shownSecret(undefined, true)).toBe('');
43
+ });
44
+ });
45
+ describe('whether the buttons do anything', () => {
46
+ it('knows when there is something to copy', () => {
47
+ expect(hasSecret('token')).toBe(true);
48
+ });
49
+ it('knows when there is not', () => {
50
+ /*
51
+ * A copy button over an absent token leaves the reader unable to tell a broken screen
52
+ * from an unissued credential.
53
+ */
54
+ expect(hasSecret('')).toBe(false);
55
+ expect(hasSecret(null)).toBe(false);
56
+ expect(hasSecret(undefined)).toBe(false);
57
+ });
58
+ });
59
+ //# sourceMappingURL=secret-display.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-display.test.js","sourceRoot":"","sources":["../../client/utils/secret-display.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAE7E;;;;GAIG;AAEH,QAAQ,CAAC,UAAU,EAAE,GAAG,EAAE;IACxB,EAAE,CAAC,qCAAqC,EAAE,GAAG,EAAE;QAC7C;;;;WAIG;QACH,MAAM,KAAK,GAAG,YAAY,CAAC,KAAK,CAAC,CAAA;QACjC,MAAM,IAAI,GAAG,YAAY,CAAC,0EAA0E,CAAC,CAAA;QAErG,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACxB,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAClC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,wBAAwB,EAAE,GAAG,EAAE;QAChC,MAAM,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC7C,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C;;;WAGG;QACH,MAAM,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACjC,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACnC,MAAM,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAC1C,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,sBAAsB,EAAE,GAAG,EAAE;IACpC,EAAE,CAAC,iCAAiC,EAAE,GAAG,EAAE;QACzC,kEAAkE;QAClE,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACvD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC3C,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAA;IAChE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,WAAW,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACtC,MAAM,CAAC,WAAW,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAC/C,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,iCAAiC,EAAE,GAAG,EAAE;IAC/C,EAAE,CAAC,uCAAuC,EAAE,GAAG,EAAE;QAC/C,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACvC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,yBAAyB,EAAE,GAAG,EAAE;QACjC;;;WAGG;QACH,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACjC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACnC,MAAM,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC1C,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { hasSecret, MASK, maskedSecret, shownSecret } from './secret-display'\n\n/**\n * The app binding and appliance detail screens print a refresh token and an access token\n * into a readonly input, in plain text, from the moment the page opens. These are the rules\n * that replace that.\n */\n\ndescribe('the mask', () => {\n it('is the same width whatever it hides', () => {\n /*\n * ⚠ The rule worth testing, because one-dot-per-character is the obvious mask and it\n * leaks. A token's length says which kind of token it is; two masked fields of\n * different widths tell a reader which is the short one.\n */\n const short = maskedSecret('abc')\n const long = maskedSecret('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefg')\n\n expect(short).toBe(long)\n expect(short.length).not.toBe(3)\n })\n\n it('says a secret is there', () => {\n expect(maskedSecret('anything')).toBe(MASK)\n })\n\n it('says nothing when there is no secret', () => {\n /*\n * Dots over an empty value send the reader hunting for a token nobody issued. The mask\n * is allowed to carry exactly one bit — present or absent — and this is it.\n */\n expect(maskedSecret('')).toBe('')\n expect(maskedSecret(null)).toBe('')\n expect(maskedSecret(undefined)).toBe('')\n })\n})\n\ndescribe('what the field shows', () => {\n it('is masked until the reader asks', () => {\n /* Revealing is an action. It is never where the screen starts. */\n expect(shownSecret('a-real-token', false)).toBe(MASK)\n })\n\n it('is the value once they have asked', () => {\n expect(shownSecret('a-real-token', true)).toBe('a-real-token')\n })\n\n it('stays empty when there is nothing, asked or not', () => {\n expect(shownSecret('', true)).toBe('')\n expect(shownSecret(undefined, true)).toBe('')\n })\n})\n\ndescribe('whether the buttons do anything', () => {\n it('knows when there is something to copy', () => {\n expect(hasSecret('token')).toBe(true)\n })\n\n it('knows when there is not', () => {\n /*\n * A copy button over an absent token leaves the reader unable to tell a broken screen\n * from an unissued credential.\n */\n expect(hasSecret('')).toBe(false)\n expect(hasSecret(null)).toBe(false)\n expect(hasSecret(undefined)).toBe(false)\n })\n})\n"]}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Reading what the server said about a passkey registration.
3
+ *
4
+ * ── Two defects that were hiding each other ─────────────────────────────────
5
+ * The profile screen posted the attestation and then did this:
6
+ *
7
+ * const verification = await fetch('/auth/verify-registration', { … }).then(res => res.json())
8
+ *
9
+ * if (verification.verified) { …registered… }
10
+ * else { console.error(await verification.text()); …refused… }
11
+ *
12
+ * `verification` is **already-parsed JSON**. It has no `.text()`. So the moment the refusal
13
+ * branch ran it threw a `TypeError`, the outer `catch` swallowed it, and the person was told
14
+ * the registration had *failed* — never that it had been *refused*.
15
+ *
16
+ * The refusal message was therefore unreachable — and behind it sat a second defect that the
17
+ * dead branch had been hiding. There were **two near-identical keys**:
18
+ *
19
+ * error.user credential regist_e_ration not allowed what the code asked for — no
20
+ * translation, in any of the five
21
+ * error.user credential registration not allowed translated in all five — no callers
22
+ *
23
+ * So even if the branch had run, the screen would have printed the key itself. Each defect
24
+ * kept the other out of sight: the dead branch meant the missing word never showed, and the
25
+ * missing word would have been the thing that made someone look. The code now asks for the
26
+ * spelling that has the words, and the misspelled entry is gone.
27
+ *
28
+ * ── Why this reads the body as text first ───────────────────────────────────
29
+ * `createWebAuthnMiddleware` does not answer `{ verified: false }` on a refusal — it **throws**
30
+ * `AuthError`, and what comes back is an error response whose body is not necessarily JSON.
31
+ * `res.json()` on it rejects, which is a second way into the same wrong branch.
32
+ *
33
+ * So the body is read **once, as text**, and parsed only if it happens to be JSON. Text always
34
+ * reads; that is the point. Whatever the server said survives into `reason` either way, which
35
+ * is what was missing from the log.
36
+ */
37
+ export type VerificationOutcome =
38
+ /** The server confirmed it. */
39
+ {
40
+ outcome: 'registered';
41
+ }
42
+ /**
43
+ * The server said no. `reason` is the server's own words, for the log — never for the
44
+ * screen: it is an error code or a passport message, in English, written for us.
45
+ */
46
+ | {
47
+ outcome: 'refused';
48
+ reason: string;
49
+ };
50
+ /**
51
+ * What the verify-registration response means.
52
+ *
53
+ * Never throws. A registration attempt has exactly two ends a person cares about, and an
54
+ * exception thrown while working out which one it was is how this screen lost the difference.
55
+ */
56
+ export declare function readVerification(response: Response): Promise<VerificationOutcome>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Reading what the server said about a passkey registration.
3
+ *
4
+ * ── Two defects that were hiding each other ─────────────────────────────────
5
+ * The profile screen posted the attestation and then did this:
6
+ *
7
+ * const verification = await fetch('/auth/verify-registration', { … }).then(res => res.json())
8
+ *
9
+ * if (verification.verified) { …registered… }
10
+ * else { console.error(await verification.text()); …refused… }
11
+ *
12
+ * `verification` is **already-parsed JSON**. It has no `.text()`. So the moment the refusal
13
+ * branch ran it threw a `TypeError`, the outer `catch` swallowed it, and the person was told
14
+ * the registration had *failed* — never that it had been *refused*.
15
+ *
16
+ * The refusal message was therefore unreachable — and behind it sat a second defect that the
17
+ * dead branch had been hiding. There were **two near-identical keys**:
18
+ *
19
+ * error.user credential regist_e_ration not allowed what the code asked for — no
20
+ * translation, in any of the five
21
+ * error.user credential registration not allowed translated in all five — no callers
22
+ *
23
+ * So even if the branch had run, the screen would have printed the key itself. Each defect
24
+ * kept the other out of sight: the dead branch meant the missing word never showed, and the
25
+ * missing word would have been the thing that made someone look. The code now asks for the
26
+ * spelling that has the words, and the misspelled entry is gone.
27
+ *
28
+ * ── Why this reads the body as text first ───────────────────────────────────
29
+ * `createWebAuthnMiddleware` does not answer `{ verified: false }` on a refusal — it **throws**
30
+ * `AuthError`, and what comes back is an error response whose body is not necessarily JSON.
31
+ * `res.json()` on it rejects, which is a second way into the same wrong branch.
32
+ *
33
+ * So the body is read **once, as text**, and parsed only if it happens to be JSON. Text always
34
+ * reads; that is the point. Whatever the server said survives into `reason` either way, which
35
+ * is what was missing from the log.
36
+ */
37
+ /**
38
+ * What the verify-registration response means.
39
+ *
40
+ * Never throws. A registration attempt has exactly two ends a person cares about, and an
41
+ * exception thrown while working out which one it was is how this screen lost the difference.
42
+ */
43
+ export async function readVerification(response) {
44
+ const body = await readBody(response);
45
+ const parsed = asJson(body);
46
+ if (response.ok && parsed?.verified === true) {
47
+ return { outcome: 'registered' };
48
+ }
49
+ return { outcome: 'refused', reason: reasonFrom(response, parsed, body) };
50
+ }
51
+ /** The body, as text. A body that cannot be read at all is not a reason to lose the outcome. */
52
+ async function readBody(response) {
53
+ try {
54
+ return await response.text();
55
+ }
56
+ catch {
57
+ return '';
58
+ }
59
+ }
60
+ function asJson(body) {
61
+ try {
62
+ return JSON.parse(body);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * The most specific thing the server said.
70
+ *
71
+ * `AuthError` carries `errorCode`; passport puts its own wording in `message`. Falling back to
72
+ * the raw body keeps a plain-text error intact, and to the status when the body is empty —
73
+ * because "refused, and it said nothing" is itself worth knowing in a log.
74
+ */
75
+ function reasonFrom(response, parsed, body) {
76
+ const said = parsed?.errorCode || parsed?.message || parsed?.error || body.trim();
77
+ return said || `HTTP ${response.status}`;
78
+ }
79
+ //# sourceMappingURL=webauthn-verification.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webauthn-verification.js","sourceRoot":"","sources":["../../client/utils/webauthn-verification.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAWH;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,QAAkB;IACvD,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,CAAA;IACrC,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,CAAA;IAE3B,IAAI,QAAQ,CAAC,EAAE,IAAI,MAAM,EAAE,QAAQ,KAAK,IAAI,EAAE,CAAC;QAC7C,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,CAAA;IAClC,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,CAAA;AAC3E,CAAC;AAED,gGAAgG;AAChG,KAAK,UAAU,QAAQ,CAAC,QAAkB;IACxC,IAAI,CAAC;QACH,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAA;IACX,CAAC;AACH,CAAC;AAED,SAAS,MAAM,CAAC,IAAY;IAC1B,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,SAAS,UAAU,CAAC,QAAkB,EAAE,MAAW,EAAE,IAAY;IAC/D,MAAM,IAAI,GAAG,MAAM,EAAE,SAAS,IAAI,MAAM,EAAE,OAAO,IAAI,MAAM,EAAE,KAAK,IAAI,IAAI,CAAC,IAAI,EAAE,CAAA;IAEjF,OAAO,IAAI,IAAI,QAAQ,QAAQ,CAAC,MAAM,EAAE,CAAA;AAC1C,CAAC","sourcesContent":["/**\n * Reading what the server said about a passkey registration.\n *\n * ── Two defects that were hiding each other ─────────────────────────────────\n * The profile screen posted the attestation and then did this:\n *\n * const verification = await fetch('/auth/verify-registration', { … }).then(res => res.json())\n *\n * if (verification.verified) { …registered… }\n * else { console.error(await verification.text()); …refused… }\n *\n * `verification` is **already-parsed JSON**. It has no `.text()`. So the moment the refusal\n * branch ran it threw a `TypeError`, the outer `catch` swallowed it, and the person was told\n * the registration had *failed* — never that it had been *refused*.\n *\n * The refusal message was therefore unreachable — and behind it sat a second defect that the\n * dead branch had been hiding. There were **two near-identical keys**:\n *\n * error.user credential regist_e_ration not allowed what the code asked for — no\n * translation, in any of the five\n * error.user credential registration not allowed translated in all five — no callers\n *\n * So even if the branch had run, the screen would have printed the key itself. Each defect\n * kept the other out of sight: the dead branch meant the missing word never showed, and the\n * missing word would have been the thing that made someone look. The code now asks for the\n * spelling that has the words, and the misspelled entry is gone.\n *\n * ── Why this reads the body as text first ───────────────────────────────────\n * `createWebAuthnMiddleware` does not answer `{ verified: false }` on a refusal — it **throws**\n * `AuthError`, and what comes back is an error response whose body is not necessarily JSON.\n * `res.json()` on it rejects, which is a second way into the same wrong branch.\n *\n * So the body is read **once, as text**, and parsed only if it happens to be JSON. Text always\n * reads; that is the point. Whatever the server said survives into `reason` either way, which\n * is what was missing from the log.\n */\n\nexport type VerificationOutcome =\n /** The server confirmed it. */\n | { outcome: 'registered' }\n /**\n * The server said no. `reason` is the server's own words, for the log — never for the\n * screen: it is an error code or a passport message, in English, written for us.\n */\n | { outcome: 'refused'; reason: string }\n\n/**\n * What the verify-registration response means.\n *\n * Never throws. A registration attempt has exactly two ends a person cares about, and an\n * exception thrown while working out which one it was is how this screen lost the difference.\n */\nexport async function readVerification(response: Response): Promise<VerificationOutcome> {\n const body = await readBody(response)\n const parsed = asJson(body)\n\n if (response.ok && parsed?.verified === true) {\n return { outcome: 'registered' }\n }\n\n return { outcome: 'refused', reason: reasonFrom(response, parsed, body) }\n}\n\n/** The body, as text. A body that cannot be read at all is not a reason to lose the outcome. */\nasync function readBody(response: Response): Promise<string> {\n try {\n return await response.text()\n } catch {\n return ''\n }\n}\n\nfunction asJson(body: string): any {\n try {\n return JSON.parse(body)\n } catch {\n return null\n }\n}\n\n/**\n * The most specific thing the server said.\n *\n * `AuthError` carries `errorCode`; passport puts its own wording in `message`. Falling back to\n * the raw body keeps a plain-text error intact, and to the status when the body is empty —\n * because \"refused, and it said nothing\" is itself worth knowing in a log.\n */\nfunction reasonFrom(response: Response, parsed: any, body: string): string {\n const said = parsed?.errorCode || parsed?.message || parsed?.error || body.trim()\n\n return said || `HTTP ${response.status}`\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};