@things-factory/figure-service 10.1.4 → 10.1.7

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 (52) hide show
  1. package/coverage/clover.xml +6 -0
  2. package/coverage/coverage-final.json +1 -0
  3. package/coverage/lcov-report/base.css +224 -0
  4. package/coverage/lcov-report/block-navigation.js +87 -0
  5. package/coverage/lcov-report/favicon.png +0 -0
  6. package/coverage/lcov-report/index.html +101 -0
  7. package/coverage/lcov-report/prettify.css +1 -0
  8. package/coverage/lcov-report/prettify.js +2 -0
  9. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  10. package/coverage/lcov-report/sorter.js +210 -0
  11. package/coverage/lcov.info +0 -0
  12. package/dist-server/index.d.ts +2 -0
  13. package/dist-server/index.js +13 -0
  14. package/dist-server/index.js.map +1 -1
  15. package/dist-server/routers/figure-thumbnail-router.d.ts +24 -0
  16. package/dist-server/routers/figure-thumbnail-router.js +93 -0
  17. package/dist-server/routers/figure-thumbnail-router.js.map +1 -0
  18. package/dist-server/routes.d.ts +1 -0
  19. package/dist-server/routes.js +21 -0
  20. package/dist-server/routes.js.map +1 -0
  21. package/dist-server/service/figure/figure-gate.d.ts +55 -0
  22. package/dist-server/service/figure/figure-gate.js +64 -0
  23. package/dist-server/service/figure/figure-gate.js.map +1 -0
  24. package/dist-server/service/figure/figure-mutation.d.ts +6 -0
  25. package/dist-server/service/figure/figure-mutation.js +21 -0
  26. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  27. package/dist-server/service/figure/figure-query.d.ts +9 -1
  28. package/dist-server/service/figure/figure-query.js +36 -1
  29. package/dist-server/service/figure/figure-query.js.map +1 -1
  30. package/dist-server/service/figure/figure-tools.d.ts +10 -0
  31. package/dist-server/service/figure/figure-tools.js +304 -0
  32. package/dist-server/service/figure/figure-tools.js.map +1 -0
  33. package/dist-server/service/figure/figure-type.d.ts +27 -0
  34. package/dist-server/service/figure/figure-type.js +66 -1
  35. package/dist-server/service/figure/figure-type.js.map +1 -1
  36. package/dist-server/service/figure/figure.d.ts +17 -1
  37. package/dist-server/service/figure/figure.js +6 -1
  38. package/dist-server/service/figure/figure.js.map +1 -1
  39. package/dist-server/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +8 -6
  41. package/server/index.ts +13 -0
  42. package/server/routers/figure-thumbnail-router.ts +110 -0
  43. package/server/routes.ts +24 -0
  44. package/server/service/figure/catalog-db.test.ts +162 -0
  45. package/server/service/figure/figure-gate.ts +119 -0
  46. package/server/service/figure/figure-mutation.ts +23 -0
  47. package/server/service/figure/figure-propose.test.ts +136 -4
  48. package/server/service/figure/figure-query.ts +34 -2
  49. package/server/service/figure/figure-tools.test.ts +284 -0
  50. package/server/service/figure/figure-tools.ts +320 -0
  51. package/server/service/figure/figure-type.ts +53 -0
  52. package/server/service/figure/figure.ts +22 -2
@@ -0,0 +1,210 @@
1
+ /* eslint-disable */
2
+ var addSorting = (function() {
3
+ 'use strict';
4
+ var cols,
5
+ currentSort = {
6
+ index: 0,
7
+ desc: false
8
+ };
9
+
10
+ // returns the summary table element
11
+ function getTable() {
12
+ return document.querySelector('.coverage-summary');
13
+ }
14
+ // returns the thead element of the summary table
15
+ function getTableHeader() {
16
+ return getTable().querySelector('thead tr');
17
+ }
18
+ // returns the tbody element of the summary table
19
+ function getTableBody() {
20
+ return getTable().querySelector('tbody');
21
+ }
22
+ // returns the th element for nth column
23
+ function getNthColumn(n) {
24
+ return getTableHeader().querySelectorAll('th')[n];
25
+ }
26
+
27
+ function onFilterInput() {
28
+ const searchValue = document.getElementById('fileSearch').value;
29
+ const rows = document.getElementsByTagName('tbody')[0].children;
30
+
31
+ // Try to create a RegExp from the searchValue. If it fails (invalid regex),
32
+ // it will be treated as a plain text search
33
+ let searchRegex;
34
+ try {
35
+ searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
36
+ } catch (error) {
37
+ searchRegex = null;
38
+ }
39
+
40
+ for (let i = 0; i < rows.length; i++) {
41
+ const row = rows[i];
42
+ let isMatch = false;
43
+
44
+ if (searchRegex) {
45
+ // If a valid regex was created, use it for matching
46
+ isMatch = searchRegex.test(row.textContent);
47
+ } else {
48
+ // Otherwise, fall back to the original plain text search
49
+ isMatch = row.textContent
50
+ .toLowerCase()
51
+ .includes(searchValue.toLowerCase());
52
+ }
53
+
54
+ row.style.display = isMatch ? '' : 'none';
55
+ }
56
+ }
57
+
58
+ // loads the search box
59
+ function addSearchBox() {
60
+ var template = document.getElementById('filterTemplate');
61
+ var templateClone = template.content.cloneNode(true);
62
+ templateClone.getElementById('fileSearch').oninput = onFilterInput;
63
+ template.parentElement.appendChild(templateClone);
64
+ }
65
+
66
+ // loads all columns
67
+ function loadColumns() {
68
+ var colNodes = getTableHeader().querySelectorAll('th'),
69
+ colNode,
70
+ cols = [],
71
+ col,
72
+ i;
73
+
74
+ for (i = 0; i < colNodes.length; i += 1) {
75
+ colNode = colNodes[i];
76
+ col = {
77
+ key: colNode.getAttribute('data-col'),
78
+ sortable: !colNode.getAttribute('data-nosort'),
79
+ type: colNode.getAttribute('data-type') || 'string'
80
+ };
81
+ cols.push(col);
82
+ if (col.sortable) {
83
+ col.defaultDescSort = col.type === 'number';
84
+ colNode.innerHTML =
85
+ colNode.innerHTML + '<span class="sorter"></span>';
86
+ }
87
+ }
88
+ return cols;
89
+ }
90
+ // attaches a data attribute to every tr element with an object
91
+ // of data values keyed by column name
92
+ function loadRowData(tableRow) {
93
+ var tableCols = tableRow.querySelectorAll('td'),
94
+ colNode,
95
+ col,
96
+ data = {},
97
+ i,
98
+ val;
99
+ for (i = 0; i < tableCols.length; i += 1) {
100
+ colNode = tableCols[i];
101
+ col = cols[i];
102
+ val = colNode.getAttribute('data-value');
103
+ if (col.type === 'number') {
104
+ val = Number(val);
105
+ }
106
+ data[col.key] = val;
107
+ }
108
+ return data;
109
+ }
110
+ // loads all row data
111
+ function loadData() {
112
+ var rows = getTableBody().querySelectorAll('tr'),
113
+ i;
114
+
115
+ for (i = 0; i < rows.length; i += 1) {
116
+ rows[i].data = loadRowData(rows[i]);
117
+ }
118
+ }
119
+ // sorts the table using the data for the ith column
120
+ function sortByIndex(index, desc) {
121
+ var key = cols[index].key,
122
+ sorter = function(a, b) {
123
+ a = a.data[key];
124
+ b = b.data[key];
125
+ return a < b ? -1 : a > b ? 1 : 0;
126
+ },
127
+ finalSorter = sorter,
128
+ tableBody = document.querySelector('.coverage-summary tbody'),
129
+ rowNodes = tableBody.querySelectorAll('tr'),
130
+ rows = [],
131
+ i;
132
+
133
+ if (desc) {
134
+ finalSorter = function(a, b) {
135
+ return -1 * sorter(a, b);
136
+ };
137
+ }
138
+
139
+ for (i = 0; i < rowNodes.length; i += 1) {
140
+ rows.push(rowNodes[i]);
141
+ tableBody.removeChild(rowNodes[i]);
142
+ }
143
+
144
+ rows.sort(finalSorter);
145
+
146
+ for (i = 0; i < rows.length; i += 1) {
147
+ tableBody.appendChild(rows[i]);
148
+ }
149
+ }
150
+ // removes sort indicators for current column being sorted
151
+ function removeSortIndicators() {
152
+ var col = getNthColumn(currentSort.index),
153
+ cls = col.className;
154
+
155
+ cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
156
+ col.className = cls;
157
+ }
158
+ // adds sort indicators for current column being sorted
159
+ function addSortIndicators() {
160
+ getNthColumn(currentSort.index).className += currentSort.desc
161
+ ? ' sorted-desc'
162
+ : ' sorted';
163
+ }
164
+ // adds event listeners for all sorter widgets
165
+ function enableUI() {
166
+ var i,
167
+ el,
168
+ ithSorter = function ithSorter(i) {
169
+ var col = cols[i];
170
+
171
+ return function() {
172
+ var desc = col.defaultDescSort;
173
+
174
+ if (currentSort.index === i) {
175
+ desc = !currentSort.desc;
176
+ }
177
+ sortByIndex(i, desc);
178
+ removeSortIndicators();
179
+ currentSort.index = i;
180
+ currentSort.desc = desc;
181
+ addSortIndicators();
182
+ };
183
+ };
184
+ for (i = 0; i < cols.length; i += 1) {
185
+ if (cols[i].sortable) {
186
+ // add the click event handler on the th so users
187
+ // dont have to click on those tiny arrows
188
+ el = getNthColumn(i).querySelector('.sorter').parentElement;
189
+ if (el.addEventListener) {
190
+ el.addEventListener('click', ithSorter(i));
191
+ } else {
192
+ el.attachEvent('onclick', ithSorter(i));
193
+ }
194
+ }
195
+ }
196
+ }
197
+ // adds sorting functionality to the UI
198
+ return function() {
199
+ if (!getTable()) {
200
+ return;
201
+ }
202
+ cols = loadColumns();
203
+ loadData();
204
+ addSearchBox();
205
+ addSortIndicators();
206
+ enableUI();
207
+ };
208
+ })();
209
+
210
+ window.addEventListener('load', addSorting);
File without changes
@@ -1,2 +1,4 @@
1
+ import './routes.js';
1
2
  export * from './service/index.js';
2
3
  export * from './migrations/index.js';
4
+ export { registerFigureToolCategories } from './service/figure/figure-tools.js';
@@ -1,6 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerFigureToolCategories = void 0;
3
4
  const tslib_1 = require("tslib");
5
+ require("./routes.js");
6
+ /*
7
+ * 저작 도구를 어시스턴트 레지스트리에 등록한다.
8
+ *
9
+ * 이 패키지가 적재되면 대화면(도크)이 저작 중인 Figure 의 정본·비용·발행 판정을 읽고 후보를
10
+ * 요청할 수 있다. 능력을 복제하지 않고 figure-service 가 이미 가진 진입점을 투영한다 —
11
+ * 노출 범위는 대화면이 `toolCategories` 로 고른다(twin-ai · board-import 와 같은 방식).
12
+ */
13
+ const figure_tools_js_1 = require("./service/figure/figure-tools.js");
14
+ (0, figure_tools_js_1.registerFigureToolCategories)();
4
15
  tslib_1.__exportStar(require("./service/index.js"), exports);
5
16
  tslib_1.__exportStar(require("./migrations/index.js"), exports);
17
+ var figure_tools_js_2 = require("./service/figure/figure-tools.js");
18
+ Object.defineProperty(exports, "registerFigureToolCategories", { enumerable: true, get: function () { return figure_tools_js_2.registerFigureToolCategories; } });
6
19
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":";;;AAAA,6DAAkC;AAClC,gEAAqC","sourcesContent":["export * from './service/index.js'\nexport * from './migrations/index.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":";;;;AAAA,uBAAoB;AAEpB;;;;;;GAMG;AACH,sEAA+E;AAC/E,IAAA,8CAA4B,GAAE,CAAA;AAE9B,6DAAkC;AAClC,gEAAqC;AACrC,oEAA+E;AAAtE,+HAAA,4BAA4B,OAAA","sourcesContent":["import './routes.js'\n\n/*\n * 저작 도구를 어시스턴트 레지스트리에 등록한다.\n *\n * 이 패키지가 적재되면 대화면(도크)이 저작 중인 Figure 의 정본·비용·발행 판정을 읽고 후보를\n * 요청할 수 있다. 능력을 복제하지 않고 figure-service 가 이미 가진 진입점을 투영한다 —\n * 노출 범위는 대화면이 `toolCategories` 로 고른다(twin-ai · board-import 와 같은 방식).\n */\nimport { registerFigureToolCategories } from './service/figure/figure-tools.js'\nregisterFigureToolCategories()\n\nexport * from './service/index.js'\nexport * from './migrations/index.js'\nexport { registerFigureToolCategories } from './service/figure/figure-tools.js'\n"]}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 썸네일을 **그림으로** 내보내는 길.
3
+ *
4
+ * ## 왜 필요한가
5
+ *
6
+ * 썸네일은 base64 문자열로 칸에 들어 있다(`Board.thumbnail` 과 같은 방식). 그것을 목록 질의가
7
+ * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은
8
+ * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.
9
+ *
10
+ * 그림으로 내보내면 브라우저가 캐시하고, 부르는 쪽이 `?v=thumbnailUpdatedAt` 을 붙이므로 그림이
11
+ * 다시 만들어진 것만 다시 받는다. 목록 질의에서는 `thumbnail` 을 아예 고르지 않는다.
12
+ *
13
+ * ## board 를 베끼면서 고친 것 셋
14
+ *
15
+ * `standalone-board-service-router` 의 `/thumbnail/:id` 가 본이지만 그대로 두면 안 되는 것들이 있다.
16
+ *
17
+ * 1. **길 이름이 겹친다.** 이 앱은 board 도 함께 싣는다(메뉴가 board-ui 를 끌어온다).
18
+ * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.
19
+ * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이
20
+ * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.
21
+ * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). data URL 이 스스로
22
+ * 말하는 형식을 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.
23
+ */
24
+ export declare const figureThumbnailRouter: any;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.figureThumbnailRouter = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const koa_router_1 = tslib_1.__importDefault(require("koa-router"));
6
+ const typeorm_1 = require("typeorm");
7
+ const auth_base_1 = require("@things-factory/auth-base");
8
+ const shell_1 = require("@things-factory/shell");
9
+ const figure_js_1 = require("../service/figure/figure.js");
10
+ /**
11
+ * 썸네일을 **그림으로** 내보내는 길.
12
+ *
13
+ * ## 왜 필요한가
14
+ *
15
+ * 썸네일은 base64 문자열로 칸에 들어 있다(`Board.thumbnail` 과 같은 방식). 그것을 목록 질의가
16
+ * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은
17
+ * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.
18
+ *
19
+ * 그림으로 내보내면 브라우저가 캐시하고, 부르는 쪽이 `?v=thumbnailUpdatedAt` 을 붙이므로 그림이
20
+ * 다시 만들어진 것만 다시 받는다. 목록 질의에서는 `thumbnail` 을 아예 고르지 않는다.
21
+ *
22
+ * ## board 를 베끼면서 고친 것 셋
23
+ *
24
+ * `standalone-board-service-router` 의 `/thumbnail/:id` 가 본이지만 그대로 두면 안 되는 것들이 있다.
25
+ *
26
+ * 1. **길 이름이 겹친다.** 이 앱은 board 도 함께 싣는다(메뉴가 board-ui 를 끌어온다).
27
+ * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.
28
+ * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이
29
+ * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.
30
+ * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). data URL 이 스스로
31
+ * 말하는 형식을 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.
32
+ */
33
+ exports.figureThumbnailRouter = new koa_router_1.default();
34
+ /** `data:image/webp;base64,....` → 형식과 바이트. 아니면 undefined. */
35
+ function decodeDataUrl(dataUrl) {
36
+ const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl);
37
+ if (!matched) {
38
+ return undefined;
39
+ }
40
+ return { type: matched[1], buffer: Buffer.from(matched[2], 'base64') };
41
+ }
42
+ exports.figureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {
43
+ const { domain, user, unsafeIP, prohibitedPrivileges } = context.state;
44
+ /*
45
+ **질의와 같은 문을 쓴다.**
46
+
47
+ `User.hasPrivilege('query','figure',…)` 만 보면 안 된다 — figure 질의는
48
+ `@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)` 로 선언되어 있고,
49
+ 도메인 소유자는 `figure:query` 권한 줄이 없어도 통과한다. 그 허용을 여기서 빼면 목록은 보이는데
50
+ 그림만 403 이 되어, 화면은 「그림이 아직 없다」고 거짓말한다. [확인함] 실제로 카드 14장이 전부
51
+ 403 이었다.
52
+
53
+ 조건을 손으로 다시 적지 않고 지시자가 쓰는 함수를 그대로 쓴다. 선언이 바뀌면 여기도 함께 바뀐다.
54
+ */
55
+ const allowed = await (0, auth_base_1.checkPermission)({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges);
56
+ if (!allowed) {
57
+ context.status = 403;
58
+ return;
59
+ }
60
+ const { id } = context.params;
61
+ /*
62
+ 읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고
63
+ 그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.
64
+ */
65
+ const found = await (0, shell_1.getRepository)(figure_js_1.Figure).findOne({
66
+ where: { domain: { id: (0, typeorm_1.In)([domain.id, domain.parentId].filter(Boolean)) }, id },
67
+ select: { id: true, thumbnail: true, thumbnailUpdatedAt: true }
68
+ });
69
+ const decoded = found?.thumbnail ? decodeDataUrl(found.thumbnail) : undefined;
70
+ if (!decoded) {
71
+ context.status = 404;
72
+ return;
73
+ }
74
+ /*
75
+ **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면
76
+ 안 된다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않고, `v` 없이 부른
77
+ 경우를 위해 시각을 함께 실어 다시 물어볼 수 있게 한다.
78
+ */
79
+ const stamp = found.thumbnailUpdatedAt;
80
+ context.type = decoded.type;
81
+ context.set('Cache-Control', 'private, max-age=31536000');
82
+ if (stamp) {
83
+ context.set('Last-Modified', new Date(stamp).toUTCString());
84
+ context.set('ETag', `"${new Date(stamp).getTime()}"`);
85
+ /* 브라우저가 가진 것이 아직 그것이면 바이트를 보내지 않는다. */
86
+ if (context.fresh) {
87
+ context.status = 304;
88
+ return;
89
+ }
90
+ }
91
+ context.body = decoded.buffer;
92
+ });
93
+ //# sourceMappingURL=figure-thumbnail-router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-thumbnail-router.js","sourceRoot":"","sources":["../../server/routers/figure-thumbnail-router.ts"],"names":[],"mappings":";;;;AAAA,oEAA+B;AAC/B,qCAA4B;AAE5B,yDAA2D;AAC3D,iDAAqD;AAErD,2DAAoD;AAEpD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACU,QAAA,qBAAqB,GAAG,IAAI,oBAAM,EAAE,CAAA;AAEjD,8DAA8D;AAC9D,SAAS,aAAa,CAAC,OAAe;IACpC,MAAM,OAAO,GAAG,8BAA8B,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IAC5D,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,EAAE,CAAA;AACxE,CAAC;AAED,6BAAqB,CAAC,GAAG,CAAC,uBAAuB,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;IACzE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,oBAAoB,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;IAEtE;;;;;;;;;;MAUE;IACF,MAAM,OAAO,GAAG,MAAM,IAAA,2BAAe,EACnC,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,EACvD,IAAI,EACJ,MAAM,EACN,QAAQ,EACR,oBAAoB,CACrB,CAAA;IAED,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;QACpB,OAAM;IACR,CAAC;IAED,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAA;IAE7B;;;MAGE;IACF,MAAM,KAAK,GAAG,MAAM,IAAA,qBAAa,EAAC,kBAAM,CAAC,CAAC,OAAO,CAAC;QAChD,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAA,YAAE,EAAC,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE;QAC/E,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE;KAChE,CAAC,CAAA;IAEF,MAAM,OAAO,GAAG,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,aAAa,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IAE7E,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;QACpB,OAAM;IACR,CAAC;IAED;;;;MAIE;IACF,MAAM,KAAK,GAAG,KAAM,CAAC,kBAAkB,CAAA;IAEvC,OAAO,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAA;IAC3B,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,2BAA2B,CAAC,CAAA;IACzD,IAAI,KAAK,EAAE,CAAC;QACV,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CAAC,CAAA;QAC3D,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,OAAO,EAAE,GAAG,CAAC,CAAA;QAErD,uCAAuC;QACvC,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;YAClB,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;YACpB,OAAM;QACR,CAAC;IACH,CAAC;IAED,OAAO,CAAC,IAAI,GAAG,OAAO,CAAC,MAAM,CAAA;AAC/B,CAAC,CAAC,CAAA","sourcesContent":["import Router from 'koa-router'\nimport { In } from 'typeorm'\n\nimport { checkPermission } from '@things-factory/auth-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { Figure } from '../service/figure/figure.js'\n\n/**\n * 썸네일을 **그림으로** 내보내는 길.\n *\n * ## 왜 필요한가\n *\n * 썸네일은 base64 문자열로 칸에 들어 있다(`Board.thumbnail` 과 같은 방식). 그것을 목록 질의가\n * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은\n * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.\n *\n * 그림으로 내보내면 브라우저가 캐시하고, 부르는 쪽이 `?v=thumbnailUpdatedAt` 을 붙이므로 그림이\n * 다시 만들어진 것만 다시 받는다. 목록 질의에서는 `thumbnail` 을 아예 고르지 않는다.\n *\n * ## board 를 베끼면서 고친 것 셋\n *\n * `standalone-board-service-router` 의 `/thumbnail/:id` 가 본이지만 그대로 두면 안 되는 것들이 있다.\n *\n * 1. **길 이름이 겹친다.** 이 앱은 board 도 함께 싣는다(메뉴가 board-ui 를 끌어온다).\n * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.\n * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이\n * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.\n * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). data URL 이 스스로\n * 말하는 형식을 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.\n */\nexport const figureThumbnailRouter = new Router()\n\n/** `data:image/webp;base64,....` → 형식과 바이트. 아니면 undefined. */\nfunction decodeDataUrl(dataUrl: string): { type: string; buffer: Buffer } | undefined {\n const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl)\n if (!matched) {\n return undefined\n }\n\n return { type: matched[1], buffer: Buffer.from(matched[2], 'base64') }\n}\n\nfigureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n\n /*\n **질의와 같은 문을 쓴다.**\n\n `User.hasPrivilege('query','figure',…)` 만 보면 안 된다 — figure 질의는\n `@privilege(category: \"figure\", privilege: \"query\", domainOwnerGranted: true)` 로 선언되어 있고,\n 도메인 소유자는 `figure:query` 권한 줄이 없어도 통과한다. 그 허용을 여기서 빼면 목록은 보이는데\n 그림만 403 이 되어, 화면은 「그림이 아직 없다」고 거짓말한다. [확인함] 실제로 카드 14장이 전부\n 403 이었다.\n\n 조건을 손으로 다시 적지 않고 지시자가 쓰는 함수를 그대로 쓴다. 선언이 바뀌면 여기도 함께 바뀐다.\n */\n const allowed = await checkPermission(\n { category: 'figure', privilege: 'query', owner: true },\n user,\n domain,\n unsafeIP,\n prohibitedPrivileges\n )\n\n if (!allowed) {\n context.status = 403\n return\n }\n\n const { id } = context.params\n\n /*\n 읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고\n 그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.\n */\n const found = await getRepository(Figure).findOne({\n where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },\n select: { id: true, thumbnail: true, thumbnailUpdatedAt: true }\n })\n\n const decoded = found?.thumbnail ? decodeDataUrl(found.thumbnail) : undefined\n\n if (!decoded) {\n context.status = 404\n return\n }\n\n /*\n **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면\n 안 된다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않고, `v` 없이 부른\n 경우를 위해 시각을 함께 실어 다시 물어볼 수 있게 한다.\n */\n const stamp = found!.thumbnailUpdatedAt\n\n context.type = decoded.type\n context.set('Cache-Control', 'private, max-age=31536000')\n if (stamp) {\n context.set('Last-Modified', new Date(stamp).toUTCString())\n context.set('ETag', `\"${new Date(stamp).getTime()}\"`)\n\n /* 브라우저가 가진 것이 아직 그것이면 바이트를 보내지 않는다. */\n if (context.fresh) {\n context.status = 304\n return\n }\n }\n\n context.body = decoded.buffer\n})\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const env_1 = require("@things-factory/env");
4
+ const shell_1 = require("@things-factory/shell");
5
+ const figure_thumbnail_router_js_1 = require("./routers/figure-thumbnail-router.js");
6
+ /**
7
+ * 도메인 경로가 주소에 들어가는 배치인가.
8
+ *
9
+ * `subdomain` 설정이 있으면 도메인은 호스트 이름으로 갈리므로 경로에 도메인이 없다. 없으면
10
+ * `/domain/{subdomain}/...` 로 갈린다 — board-service 가 같은 판단을 같은 방식으로 한다.
11
+ */
12
+ const isPathBaseDomain = !env_1.config.get('subdomain');
13
+ process.on('bootstrap-module-domain-private-route', (app, domainPrivateRouter) => {
14
+ if (isPathBaseDomain) {
15
+ domainPrivateRouter.use(`/${(0, shell_1.getRoutePrefixForDomainType)()}/:domain`, figure_thumbnail_router_js_1.figureThumbnailRouter.routes(), figure_thumbnail_router_js_1.figureThumbnailRouter.allowedMethods());
16
+ }
17
+ else {
18
+ domainPrivateRouter.use('', figure_thumbnail_router_js_1.figureThumbnailRouter.routes(), figure_thumbnail_router_js_1.figureThumbnailRouter.allowedMethods());
19
+ }
20
+ });
21
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../server/routes.ts"],"names":[],"mappings":";;AAAA,6CAA4C;AAC5C,iDAAmE;AAEnE,qFAA4E;AAE5E;;;;;GAKG;AACH,MAAM,gBAAgB,GAAG,CAAC,YAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAA;AAEjD,OAAO,CAAC,EAAE,CAAC,uCAA8C,EAAE,CAAC,GAAG,EAAE,mBAAmB,EAAE,EAAE;IACtF,IAAI,gBAAgB,EAAE,CAAC;QACrB,mBAAmB,CAAC,GAAG,CACrB,IAAI,IAAA,mCAA2B,GAAE,UAAU,EAC3C,kDAAqB,CAAC,MAAM,EAAE,EAC9B,kDAAqB,CAAC,cAAc,EAAE,CACvC,CAAA;IACH,CAAC;SAAM,CAAC;QACN,mBAAmB,CAAC,GAAG,CAAC,EAAE,EAAE,kDAAqB,CAAC,MAAM,EAAE,EAAE,kDAAqB,CAAC,cAAc,EAAE,CAAC,CAAA;IACrG,CAAC;AACH,CAAC,CAAC,CAAA","sourcesContent":["import { config } from '@things-factory/env'\nimport { getRoutePrefixForDomainType } from '@things-factory/shell'\n\nimport { figureThumbnailRouter } from './routers/figure-thumbnail-router.js'\n\n/**\n * 도메인 경로가 주소에 들어가는 배치인가.\n *\n * `subdomain` 설정이 있으면 도메인은 호스트 이름으로 갈리므로 경로에 도메인이 없다. 없으면\n * `/domain/{subdomain}/...` 로 갈린다 — board-service 가 같은 판단을 같은 방식으로 한다.\n */\nconst isPathBaseDomain = !config.get('subdomain')\n\nprocess.on('bootstrap-module-domain-private-route' as any, (app, domainPrivateRouter) => {\n if (isPathBaseDomain) {\n domainPrivateRouter.use(\n `/${getRoutePrefixForDomainType()}/:domain`,\n figureThumbnailRouter.routes(),\n figureThumbnailRouter.allowedMethods()\n )\n } else {\n domainPrivateRouter.use('', figureThumbnailRouter.routes(), figureThumbnailRouter.allowedMethods())\n }\n})\n"]}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 발행해도 되나 — **서버가 판정한다.**
3
+ *
4
+ * ## 왜 서버인가
5
+ *
6
+ * 이 검사는 브라우저 하네스에만 있었다(`test/browser/check-figure-scaling.mjs`). 사람이 돌릴 때만
7
+ * 돌았고, 발행 경로에는 아무 검사도 없었다 — 형식만 맞으면 무엇이든 발행되었다. 발행은 소비처에게
8
+ * 하는 약속이므로, 그 약속을 지킬 수 있는지는 약속하는 자리가 재야 한다.
9
+ *
10
+ * ## 무엇을 막고 무엇을 알리나
11
+ *
12
+ * 판정은 `figure-model` 의 `gate.ts` 가 이미 문서로 정해 두었다. 여기서 새로 정하지 않는다.
13
+ *
14
+ * sizing-splits 크기를 바꾸면 조각이 갈라진다 **막는다**
15
+ * sizing-loose 1배에서 이미 여러 조각이다 알린다
16
+ * violations 형식이 권하지 않는 것 알린다 (저장 때 이미 보여 준 것들)
17
+ *
18
+ * 갈라짐을 막는 이유는 사람 눈에 「부서졌다」로 보이기 때문이다 — 시그널타워를 3배로 세우니 램프가
19
+ * 기둥 위 공중에 떴던 그것이고, 앵커 규칙이 생긴 이유다. 소비처가 형상을 늘려 쓰는 것은 막을 수
20
+ * 없으니(그것이 이 형식의 값이다), 늘려도 견디는지를 발행 전에 본다.
21
+ *
22
+ * ## 재 본 값
23
+ *
24
+ * [확인함] 2026-09-09 · 표본 15개(시드 14 + 저작 1)
25
+ * 형식 오류 0 · 1배 분리 0 · **갈라짐 1**(MIXER, z·y-z 조합) · 조용함 14
26
+ *
27
+ * 게이트가 벽이 아니라는 뜻이다. 열넷은 그대로 발행되고, 하나는 고칠 것이 있다.
28
+ */
29
+ /** 게이트가 낸 한 가지. `blocking` 이 참이면 발행이 안 된다. */
30
+ export interface GateFinding {
31
+ code: string;
32
+ message: string;
33
+ /**
34
+ * **왜 그것이 문제인가.** 잰 것만 보여 주면 읽는 사람이 「그래서?」에서 멈춘다 — 화면에
35
+ * 「1 조각이 2 조각으로 갈라진다」만 떴을 때 실제로 그랬다.
36
+ */
37
+ why?: string;
38
+ /** **무엇을 바꾸면 되나.** 부품 이름과 값으로. 형식이 만든 문장을 그대로 옮긴다. */
39
+ how?: string;
40
+ /** 어디를 가리키나 — 갈라짐은 갈라진 부품 이름들, 형식 위반은 정본 안의 경로. */
41
+ at?: string;
42
+ blocking: boolean;
43
+ }
44
+ export interface Inspection {
45
+ findings: GateFinding[];
46
+ /** 막는 것이 하나라도 있나. 화면이 이것으로 단추를 잠근다. */
47
+ blocked: boolean;
48
+ }
49
+ /**
50
+ * 정본 하나를 판정한다.
51
+ *
52
+ * 정본이 없거나(아직 저장 전) JSON 이 아니면 **막는다** — 발행할 대상이 없는 것이다. 그 경우를
53
+ * 조용히 통과시키면 빈 것이 발행된다.
54
+ */
55
+ export declare function inspectSource(sourceJson?: string | null): Inspection;
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.inspectSource = inspectSource;
4
+ const figure_model_1 = require("@hatiolab/figure-model");
5
+ /**
6
+ * 정본 하나를 판정한다.
7
+ *
8
+ * 정본이 없거나(아직 저장 전) JSON 이 아니면 **막는다** — 발행할 대상이 없는 것이다. 그 경우를
9
+ * 조용히 통과시키면 빈 것이 발행된다.
10
+ */
11
+ function inspectSource(sourceJson) {
12
+ if (!sourceJson) {
13
+ return {
14
+ blocked: true,
15
+ findings: [
16
+ {
17
+ code: 'no-source',
18
+ message: '저장된 정본이 없다. 저장하면 발행할 수 있다',
19
+ blocking: true
20
+ }
21
+ ]
22
+ };
23
+ }
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(sourceJson);
27
+ }
28
+ catch (e) {
29
+ return {
30
+ blocked: true,
31
+ findings: [{ code: 'source-not-json', message: `정본이 JSON 이 아니다: ${e.message}`, blocking: true }]
32
+ };
33
+ }
34
+ const result = (0, figure_model_1.validate)(parsed);
35
+ const findings = [];
36
+ /*
37
+ 형식 오류는 **저장을 통과할 수 없다**(`parseSource` 가 거절한다). 그래도 여기서 다시 보는 이유는,
38
+ 이 판정이 저장 경로와 독립이어야 하기 때문이다 — 옛 정본이 남아 있거나 형식이 엄해진 뒤라면
39
+ 저장 때는 맞았던 것이 지금은 틀릴 수 있다. 그때 발행이 통과하면 안 된다.
40
+ */
41
+ for (const error of result.errors) {
42
+ findings.push({ code: error.code, message: error.message, at: error.path, blocking: true });
43
+ }
44
+ for (const violation of result.violations) {
45
+ findings.push({ code: violation.code, message: violation.message, blocking: false });
46
+ }
47
+ if (result.errors.length === 0) {
48
+ const report = (0, figure_model_1.sizingReport)((0, figure_model_1.compile)(parsed));
49
+ for (const finding of report.findings) {
50
+ findings.push({
51
+ code: finding.code,
52
+ message: finding.message,
53
+ /* 왜·어떻게는 형식이 소유한다. 서버가 다시 쓰면 두 벌이 되고 곧 어긋난다. */
54
+ why: finding.why,
55
+ how: finding.how,
56
+ /* 숫자가 아니라 자리를 말한다 — 어느 부품들이 갈라져 나왔나. */
57
+ at: finding.clumps.map(clump => [...clump].sort().join('+')).join(' | ') || undefined,
58
+ blocking: finding.code === 'sizing-splits'
59
+ });
60
+ }
61
+ }
62
+ return { findings, blocked: findings.some(finding => finding.blocking) };
63
+ }
64
+ //# sourceMappingURL=figure-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-gate.js","sourceRoot":"","sources":["../../../server/service/figure/figure-gate.ts"],"names":[],"mappings":";;AA4DA,sCA0DC;AAtHD,yDAAwE;AAsDxE;;;;;GAKG;AACH,SAAgB,aAAa,CAAC,UAA0B;IACtD,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO;YACL,OAAO,EAAE,IAAI;YACb,QAAQ,EAAE;gBACR;oBACE,IAAI,EAAE,WAAW;oBACjB,OAAO,EAAE,2BAA2B;oBACpC,QAAQ,EAAE,IAAI;iBACf;aACF;SACF,CAAA;IACH,CAAC;IAED,IAAI,MAAe,CAAA;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;IACjC,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO;YACL,OAAO,EAAE,IAAI;YACb,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,mBAAoB,CAAW,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;SAC5G,CAAA;IACH,CAAC;IAED,MAAM,MAAM,GAAG,IAAA,uBAAQ,EAAC,MAAM,CAAC,CAAA;IAC/B,MAAM,QAAQ,GAAkB,EAAE,CAAA;IAElC;;;;MAIE;IACF,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;QAClC,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,EAAE,EAAE,KAAK,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IAC7F,CAAC;IAED,KAAK,MAAM,SAAS,IAAI,MAAM,CAAC,UAAU,EAAE,CAAC;QAC1C,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAA;IACtF,CAAC;IAED,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC/B,MAAM,MAAM,GAAG,IAAA,2BAAY,EAAC,IAAA,sBAAO,EAAC,MAAsB,CAAC,CAAC,CAAA;QAE5D,KAAK,MAAM,OAAO,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;YACtC,QAAQ,CAAC,IAAI,CAAC;gBACZ,IAAI,EAAE,OAAO,CAAC,IAAI;gBAClB,OAAO,EAAE,OAAO,CAAC,OAAO;gBACxB,gDAAgD;gBAChD,GAAG,EAAE,OAAO,CAAC,GAAG;gBAChB,GAAG,EAAE,OAAO,CAAC,GAAG;gBAChB,wCAAwC;gBACxC,EAAE,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,SAAS;gBACrF,QAAQ,EAAE,OAAO,CAAC,IAAI,KAAK,eAAe;aAC3C,CAAC,CAAA;QACJ,CAAC;IACH,CAAC;IAED,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAA;AAC1E,CAAC","sourcesContent":["import { compile, sizingReport, validate } from '@hatiolab/figure-model'\nimport type { FigureSource } from '@hatiolab/figure-model'\n\n/**\n * 발행해도 되나 — **서버가 판정한다.**\n *\n * ## 왜 서버인가\n *\n * 이 검사는 브라우저 하네스에만 있었다(`test/browser/check-figure-scaling.mjs`). 사람이 돌릴 때만\n * 돌았고, 발행 경로에는 아무 검사도 없었다 — 형식만 맞으면 무엇이든 발행되었다. 발행은 소비처에게\n * 하는 약속이므로, 그 약속을 지킬 수 있는지는 약속하는 자리가 재야 한다.\n *\n * ## 무엇을 막고 무엇을 알리나\n *\n * 판정은 `figure-model` 의 `gate.ts` 가 이미 문서로 정해 두었다. 여기서 새로 정하지 않는다.\n *\n * sizing-splits 크기를 바꾸면 조각이 갈라진다 **막는다**\n * sizing-loose 1배에서 이미 여러 조각이다 알린다\n * violations 형식이 권하지 않는 것 알린다 (저장 때 이미 보여 준 것들)\n *\n * 갈라짐을 막는 이유는 사람 눈에 「부서졌다」로 보이기 때문이다 — 시그널타워를 3배로 세우니 램프가\n * 기둥 위 공중에 떴던 그것이고, 앵커 규칙이 생긴 이유다. 소비처가 형상을 늘려 쓰는 것은 막을 수\n * 없으니(그것이 이 형식의 값이다), 늘려도 견디는지를 발행 전에 본다.\n *\n * ## 재 본 값\n *\n * [확인함] 2026-09-09 · 표본 15개(시드 14 + 저작 1)\n * 형식 오류 0 · 1배 분리 0 · **갈라짐 1**(MIXER, z·y-z 조합) · 조용함 14\n *\n * 게이트가 벽이 아니라는 뜻이다. 열넷은 그대로 발행되고, 하나는 고칠 것이 있다.\n */\n\n/** 게이트가 낸 한 가지. `blocking` 이 참이면 발행이 안 된다. */\nexport interface GateFinding {\n code: string\n message: string\n /**\n * **왜 그것이 문제인가.** 잰 것만 보여 주면 읽는 사람이 「그래서?」에서 멈춘다 — 화면에\n * 「1 조각이 2 조각으로 갈라진다」만 떴을 때 실제로 그랬다.\n */\n why?: string\n /** **무엇을 바꾸면 되나.** 부품 이름과 값으로. 형식이 만든 문장을 그대로 옮긴다. */\n how?: string\n /** 어디를 가리키나 — 갈라짐은 갈라진 부품 이름들, 형식 위반은 정본 안의 경로. */\n at?: string\n blocking: boolean\n}\n\nexport interface Inspection {\n findings: GateFinding[]\n /** 막는 것이 하나라도 있나. 화면이 이것으로 단추를 잠근다. */\n blocked: boolean\n}\n\n/**\n * 정본 하나를 판정한다.\n *\n * 정본이 없거나(아직 저장 전) JSON 이 아니면 **막는다** — 발행할 대상이 없는 것이다. 그 경우를\n * 조용히 통과시키면 빈 것이 발행된다.\n */\nexport function inspectSource(sourceJson?: string | null): Inspection {\n if (!sourceJson) {\n return {\n blocked: true,\n findings: [\n {\n code: 'no-source',\n message: '저장된 정본이 없다. 저장하면 발행할 수 있다',\n blocking: true\n }\n ]\n }\n }\n\n let parsed: unknown\n try {\n parsed = JSON.parse(sourceJson)\n } catch (e) {\n return {\n blocked: true,\n findings: [{ code: 'source-not-json', message: `정본이 JSON 이 아니다: ${(e as Error).message}`, blocking: true }]\n }\n }\n\n const result = validate(parsed)\n const findings: GateFinding[] = []\n\n /*\n 형식 오류는 **저장을 통과할 수 없다**(`parseSource` 가 거절한다). 그래도 여기서 다시 보는 이유는,\n 이 판정이 저장 경로와 독립이어야 하기 때문이다 — 옛 정본이 남아 있거나 형식이 엄해진 뒤라면\n 저장 때는 맞았던 것이 지금은 틀릴 수 있다. 그때 발행이 통과하면 안 된다.\n */\n for (const error of result.errors) {\n findings.push({ code: error.code, message: error.message, at: error.path, blocking: true })\n }\n\n for (const violation of result.violations) {\n findings.push({ code: violation.code, message: violation.message, blocking: false })\n }\n\n if (result.errors.length === 0) {\n const report = sizingReport(compile(parsed as FigureSource))\n\n for (const finding of report.findings) {\n findings.push({\n code: finding.code,\n message: finding.message,\n /* 왜·어떻게는 형식이 소유한다. 서버가 다시 쓰면 두 벌이 되고 곧 어긋난다. */\n why: finding.why,\n how: finding.how,\n /* 숫자가 아니라 자리를 말한다 — 어느 부품들이 갈라져 나왔나. */\n at: finding.clumps.map(clump => [...clump].sort().join('+')).join(' | ') || undefined,\n blocking: finding.code === 'sizing-splits'\n })\n }\n }\n\n return { findings, blocked: findings.some(finding => finding.blocking) }\n}\n"]}
@@ -21,6 +21,12 @@ export declare class FigureMutation {
21
21
  *
22
22
  * 도형의 현재 번호가 아니라 **판본의 최대 번호 + 1** 이다. 되돌리기로 옛 판이 현재가 되어도
23
23
  * 다음 발행이 이미 쓴 번호를 다시 쓰지 않는다.
24
+ *
25
+ * ## 게이트를 통과해야 한다
26
+ *
27
+ * 크기를 바꿨을 때 조각이 갈라지는 도형은 발행되지 않는다(`figure-gate.ts`). 소비처는 형상을
28
+ * 늘려 쓰므로 — 그것이 이 형식의 값이다 — 늘려도 견디는지가 약속의 일부다. 화면은 같은 판정을
29
+ * `inspectFigure` 로 미리 물어 이유를 보여 준다.
24
30
  */
25
31
  releaseFigure(id: string, comment: string, context: ResolverContext): Promise<Figure>;
26
32
  /**
@@ -8,6 +8,7 @@ const figure_js_1 = require("./figure.js");
8
8
  const figure_history_js_1 = require("./figure-history.js");
9
9
  const figure_type_js_1 = require("./figure-type.js");
10
10
  const figure_propose_type_js_1 = require("./figure-propose-type.js");
11
+ const figure_gate_js_1 = require("./figure-gate.js");
11
12
  const figure_source_js_1 = require("./figure-source.js");
12
13
  const figure_propose_js_1 = require("./figure-propose.js");
13
14
  let FigureMutation = class FigureMutation {
@@ -104,6 +105,12 @@ let FigureMutation = class FigureMutation {
104
105
  *
105
106
  * 도형의 현재 번호가 아니라 **판본의 최대 번호 + 1** 이다. 되돌리기로 옛 판이 현재가 되어도
106
107
  * 다음 발행이 이미 쓴 번호를 다시 쓰지 않는다.
108
+ *
109
+ * ## 게이트를 통과해야 한다
110
+ *
111
+ * 크기를 바꿨을 때 조각이 갈라지는 도형은 발행되지 않는다(`figure-gate.ts`). 소비처는 형상을
112
+ * 늘려 쓰므로 — 그것이 이 형식의 값이다 — 늘려도 견디는지가 약속의 일부다. 화면은 같은 판정을
113
+ * `inspectFigure` 로 미리 물어 이유를 보여 준다.
107
114
  */
108
115
  async releaseFigure(id, comment, context) {
109
116
  const { domain, user, tx } = context.state;
@@ -118,6 +125,20 @@ let FigureMutation = class FigureMutation {
118
125
  if (figure.state === 'released') {
119
126
  throw new Error(`Figure '${figure.type}' 은 이미 발행되어 있다. 고쳐서 다시 내려면 초안으로 되돌린 뒤 발행한다`);
120
127
  }
128
+ /*
129
+ ── 게이트는 여기 있어야 한다 ────────────────────────────────────────────
130
+ 화면도 같은 판정을 미리 물어(`inspectFigure`) 이유를 보여 주지만, **막는 자리는 이곳뿐이다.**
131
+ 물어본 뒤 정본이 바뀌었을 수 있고, 화면을 거치지 않는 부름도 있다.
132
+
133
+ 막는 이유는 사유와 함께 낸다. 「발행할 수 없습니다」로 뭉치면 무엇을 고쳐야 하는지 모른다.
134
+ */
135
+ const inspection = (0, figure_gate_js_1.inspectSource)(figure.source);
136
+ if (inspection.blocked) {
137
+ const reasons = inspection.findings
138
+ .filter(finding => finding.blocking)
139
+ .map(finding => (finding.at ? `${finding.message} (${finding.at})` : finding.message));
140
+ throw new Error(`Figure '${figure.type}' 은 아직 발행할 수 없다 — ${reasons.join(' · ')}`);
141
+ }
121
142
  const highest = await historyRepository
122
143
  .createQueryBuilder('history')
123
144
  .select('MAX(history.version)', 'max')