@things-factory/figure-service 10.1.178 → 10.1.179

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 (82) hide show
  1. package/dist-server/index.d.ts +2 -0
  2. package/dist-server/index.js +6 -1
  3. package/dist-server/index.js.map +1 -1
  4. package/dist-server/routers/figure-mesh-router.js +4 -1
  5. package/dist-server/routers/figure-mesh-router.js.map +1 -1
  6. package/dist-server/routers/figure-thumbnail-router.js +3 -7
  7. package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
  8. package/dist-server/service/figure/figure-categories.d.ts +10 -0
  9. package/dist-server/service/figure/figure-categories.js +53 -0
  10. package/dist-server/service/figure/figure-categories.js.map +1 -0
  11. package/dist-server/service/figure/figure-format.d.ts +18 -1
  12. package/dist-server/service/figure/figure-format.js +28 -1
  13. package/dist-server/service/figure/figure-format.js.map +1 -1
  14. package/dist-server/service/figure/figure-mutation.d.ts +8 -2
  15. package/dist-server/service/figure/figure-mutation.js +83 -34
  16. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  17. package/dist-server/service/figure/figure-namespace-entity.d.ts +24 -0
  18. package/dist-server/service/figure/figure-namespace-entity.js +87 -0
  19. package/dist-server/service/figure/figure-namespace-entity.js.map +1 -0
  20. package/dist-server/service/figure/figure-namespace-resolver.d.ts +5 -8
  21. package/dist-server/service/figure/figure-namespace-resolver.js +52 -36
  22. package/dist-server/service/figure/figure-namespace-resolver.js.map +1 -1
  23. package/dist-server/service/figure/figure-namespace.d.ts +24 -28
  24. package/dist-server/service/figure/figure-namespace.js +99 -64
  25. package/dist-server/service/figure/figure-namespace.js.map +1 -1
  26. package/dist-server/service/figure/figure-package-export.js +4 -2
  27. package/dist-server/service/figure/figure-package-export.js.map +1 -1
  28. package/dist-server/service/figure/figure-public.d.ts +19 -0
  29. package/dist-server/service/figure/figure-public.js +54 -0
  30. package/dist-server/service/figure/figure-public.js.map +1 -0
  31. package/dist-server/service/figure/figure-query.d.ts +7 -0
  32. package/dist-server/service/figure/figure-query.js +59 -5
  33. package/dist-server/service/figure/figure-query.js.map +1 -1
  34. package/dist-server/service/figure/figure-rename.d.ts +25 -2
  35. package/dist-server/service/figure/figure-rename.js +21 -2
  36. package/dist-server/service/figure/figure-rename.js.map +1 -1
  37. package/dist-server/service/figure/figure-tools.js +1 -1
  38. package/dist-server/service/figure/figure-tools.js.map +1 -1
  39. package/dist-server/service/figure/figure-type.d.ts +1 -0
  40. package/dist-server/service/figure/figure-type.js +4 -0
  41. package/dist-server/service/figure/figure-type.js.map +1 -1
  42. package/dist-server/service/figure/figure.d.ts +1 -0
  43. package/dist-server/service/figure/figure.js +5 -0
  44. package/dist-server/service/figure/figure.js.map +1 -1
  45. package/dist-server/service/figure/index.d.ts +2 -1
  46. package/dist-server/service/figure/index.js +2 -1
  47. package/dist-server/service/figure/index.js.map +1 -1
  48. package/dist-server/service/index.d.ts +1 -1
  49. package/dist-server/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +7 -7
  51. package/server/index.ts +2 -0
  52. package/server/routers/figure-mesh-router.ts +5 -1
  53. package/server/routers/figure-thumbnail-router.ts +3 -5
  54. package/server/service/figure/figure-categories.ts +50 -0
  55. package/server/service/figure/figure-category-db.test.ts +127 -0
  56. package/server/service/figure/figure-deprecation-db.test.ts +110 -6
  57. package/server/service/figure/figure-format.ts +43 -1
  58. package/server/service/figure/figure-mesh-db.test.ts +4 -2
  59. package/server/service/figure/figure-mutation.ts +79 -37
  60. package/server/service/figure/figure-namespace-db.test.ts +120 -74
  61. package/server/service/figure/figure-namespace-entity.ts +67 -0
  62. package/server/service/figure/figure-namespace-resolver.ts +39 -26
  63. package/server/service/figure/figure-namespace.ts +116 -82
  64. package/server/service/figure/figure-package-export.ts +6 -3
  65. package/server/service/figure/figure-proposal-db.test.ts +5 -3
  66. package/server/service/figure/figure-public-db.test.ts +180 -0
  67. package/server/service/figure/figure-public.ts +60 -0
  68. package/server/service/figure/figure-query.ts +48 -5
  69. package/server/service/figure/figure-release-check.test.ts +63 -0
  70. package/server/service/figure/figure-release-record-db.test.ts +7 -5
  71. package/server/service/figure/figure-rename-db.test.ts +50 -4
  72. package/server/service/figure/figure-rename.ts +37 -3
  73. package/server/service/figure/figure-tools.ts +1 -1
  74. package/server/service/figure/figure-type.ts +3 -0
  75. package/server/service/figure/figure.ts +8 -0
  76. package/server/service/figure/index.ts +2 -1
  77. package/translations/en.json +12 -3
  78. package/translations/es.json +12 -3
  79. package/translations/ja.json +12 -3
  80. package/translations/ko.json +12 -3
  81. package/translations/ms.json +12 -3
  82. package/translations/zh.json +12 -3
@@ -4,3 +4,5 @@ import './service/figure/figure-thumbnail-reader.js';
4
4
  export * from './service/index.js';
5
5
  export * from './migrations/index.js';
6
6
  export { registerFigureToolCategories } from './service/figure/figure-tools.js';
7
+ export { registerFigureCategories, figureCategories } from './service/figure/figure-categories.js';
8
+ export { registerFigureReleaseCheck, type FigureReleaseCheck, type FigureReleaseCheckInput, type GateFinding } from './service/figure/figure-format.js';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.registerFigureToolCategories = void 0;
3
+ exports.registerFigureReleaseCheck = exports.figureCategories = exports.registerFigureCategories = exports.registerFigureToolCategories = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  require("./routes.js");
6
6
  /*
@@ -44,4 +44,9 @@ tslib_1.__exportStar(require("./service/index.js"), exports);
44
44
  tslib_1.__exportStar(require("./migrations/index.js"), exports);
45
45
  var figure_tools_js_2 = require("./service/figure/figure-tools.js");
46
46
  Object.defineProperty(exports, "registerFigureToolCategories", { enumerable: true, get: function () { return figure_tools_js_2.registerFigureToolCategories; } });
47
+ var figure_categories_js_1 = require("./service/figure/figure-categories.js");
48
+ Object.defineProperty(exports, "registerFigureCategories", { enumerable: true, get: function () { return figure_categories_js_1.registerFigureCategories; } });
49
+ Object.defineProperty(exports, "figureCategories", { enumerable: true, get: function () { return figure_categories_js_1.figureCategories; } });
50
+ var figure_format_js_1 = require("./service/figure/figure-format.js");
51
+ Object.defineProperty(exports, "registerFigureReleaseCheck", { enumerable: true, get: function () { return figure_format_js_1.registerFigureReleaseCheck; } });
47
52
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
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;AAC9B,oGAAoG;AACpG,8CAA2C;AAE3C;;;GAGG;AACH,mGAAoF;AACpF,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,IAAA,4CAAe,GAAE,CAAA;IAChD,OAAO,CAAC,GAAG,CAAC,+CAA+C,SAAS,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAC7I,CAAC,CAAC,CAAA;AAEF;;;GAGG;AACH,iDAAiE;AACjE,8FAAmF;AACnF,+GAA+G;AAC/G,uDAAoD;AACpD,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,IAAA,kBAAU,EAAC,GAAG,EAAE,CAAC,IAAA,kDAAoB,EAAC,IAAA,qBAAa,GAAE,CAAC,CAAC;SACpD,IAAI,CAAC,KAAK,CAAC,EAAE;QACZ,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,IAAI,CAAC,CAAA;QAChD,IAAI,GAAG,CAAC,MAAM;YAAE,OAAO,CAAC,GAAG,CAAC,gDAAgD,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAChL,CAAC,CAAC;SACD,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,mCAAmC,KAAK,EAAE,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAA;AAChG,CAAC,CAAC,CAAA;AAEF,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/* The figure the modeller has open, said to the AI each turn (design/ai/screen-pattern.md §2-1). */\nimport './service/figure/figure-subject.js'\n\n/*\n * The recipe templates are read once at start (ADR-0095, amended): what was served, and any file whose form is wrong,\n * said here rather than found by a person as a missing template.\n */\nimport { recipeTemplates } from './service/figure-recipe/figure-recipe-templates.js'\nprocess.on('bootstrap-module-start' as any, () => {\n const { templates, refused } = recipeTemplates()\n console.log(`[figure-service:bootstrap] recipe templates ${templates.length}${refused.length ? `, left out: ${refused.join(' | ')}` : ''}`)\n})\n\n/*\n * The pictures an install kept as base64 move into attachment-base by their content, once, at start (ADR-0104 decision\n * 6). In the boot's one line: it writes, and sqlite holds one runner. A failure is said and the server goes on.\n */\nimport { getDataSource, inBootTurn } from '@things-factory/shell'\nimport { moveBase64Thumbnails } from './service/figure/figure-thumbnail-upgrade.js'\n/* Who may read a figure picture by its attachment path (ADR-0106): registered with attachment-base at load. */\nimport './service/figure/figure-thumbnail-reader.js'\nprocess.on('bootstrap-module-start' as any, () => {\n inBootTurn(() => moveBase64Thumbnails(getDataSource()))\n .then(moved => {\n const any = moved.filter(m => m.moved || m.kept)\n if (any.length) console.log(`[figure-service:bootstrap] thumbnails moved: ${any.map(m => `${m.table} ${m.moved}${m.kept ? ` (kept as base64: ${m.kept})` : ''}`).join(', ')}`)\n })\n .catch(error => console.error(`[figure-service] 썸네일을 옮기지 못했다 — ${error?.message ?? error}`))\n})\n\nexport * from './service/index.js'\nexport * from './migrations/index.js'\nexport { registerFigureToolCategories } from './service/figure/figure-tools.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;AAC9B,oGAAoG;AACpG,8CAA2C;AAE3C;;;GAGG;AACH,mGAAoF;AACpF,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,IAAA,4CAAe,GAAE,CAAA;IAChD,OAAO,CAAC,GAAG,CAAC,+CAA+C,SAAS,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAC7I,CAAC,CAAC,CAAA;AAEF;;;GAGG;AACH,iDAAiE;AACjE,8FAAmF;AACnF,+GAA+G;AAC/G,uDAAoD;AACpD,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,IAAA,kBAAU,EAAC,GAAG,EAAE,CAAC,IAAA,kDAAoB,EAAC,IAAA,qBAAa,GAAE,CAAC,CAAC;SACpD,IAAI,CAAC,KAAK,CAAC,EAAE;QACZ,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,IAAI,CAAC,CAAA;QAChD,IAAI,GAAG,CAAC,MAAM;YAAE,OAAO,CAAC,GAAG,CAAC,gDAAgD,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAChL,CAAC,CAAC;SACD,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,mCAAmC,KAAK,EAAE,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAA;AAChG,CAAC,CAAC,CAAA;AAEF,6DAAkC;AAClC,gEAAqC;AACrC,oEAA+E;AAAtE,+HAAA,4BAA4B,OAAA;AACrC,8EAAkG;AAAzF,gIAAA,wBAAwB,OAAA;AAAE,wHAAA,gBAAgB,OAAA;AACnD,sEAAuJ;AAA9I,8HAAA,0BAA0B,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/* The figure the modeller has open, said to the AI each turn (design/ai/screen-pattern.md §2-1). */\nimport './service/figure/figure-subject.js'\n\n/*\n * The recipe templates are read once at start (ADR-0095, amended): what was served, and any file whose form is wrong,\n * said here rather than found by a person as a missing template.\n */\nimport { recipeTemplates } from './service/figure-recipe/figure-recipe-templates.js'\nprocess.on('bootstrap-module-start' as any, () => {\n const { templates, refused } = recipeTemplates()\n console.log(`[figure-service:bootstrap] recipe templates ${templates.length}${refused.length ? `, left out: ${refused.join(' | ')}` : ''}`)\n})\n\n/*\n * The pictures an install kept as base64 move into attachment-base by their content, once, at start (ADR-0104 decision\n * 6). In the boot's one line: it writes, and sqlite holds one runner. A failure is said and the server goes on.\n */\nimport { getDataSource, inBootTurn } from '@things-factory/shell'\nimport { moveBase64Thumbnails } from './service/figure/figure-thumbnail-upgrade.js'\n/* Who may read a figure picture by its attachment path (ADR-0106): registered with attachment-base at load. */\nimport './service/figure/figure-thumbnail-reader.js'\nprocess.on('bootstrap-module-start' as any, () => {\n inBootTurn(() => moveBase64Thumbnails(getDataSource()))\n .then(moved => {\n const any = moved.filter(m => m.moved || m.kept)\n if (any.length) console.log(`[figure-service:bootstrap] thumbnails moved: ${any.map(m => `${m.table} ${m.moved}${m.kept ? ` (kept as base64: ${m.kept})` : ''}`).join(', ')}`)\n })\n .catch(error => console.error(`[figure-service] 썸네일을 옮기지 못했다 — ${error?.message ?? error}`))\n})\n\nexport * from './service/index.js'\nexport * from './migrations/index.js'\nexport { registerFigureToolCategories } from './service/figure/figure-tools.js'\nexport { registerFigureCategories, figureCategories } from './service/figure/figure-categories.js'\nexport { registerFigureReleaseCheck, type FigureReleaseCheck, type FigureReleaseCheckInput, type GateFinding } from './service/figure/figure-format.js'\n"]}
@@ -9,6 +9,7 @@ const shell_1 = require("@things-factory/shell");
9
9
  const figure_mesh_store_js_1 = require("../service/figure/figure-mesh-store.js");
10
10
  const figure_glb_js_1 = require("../service/figure/figure-glb.js");
11
11
  const figure_thumbnail_store_js_1 = require("../service/figure/figure-thumbnail-store.js");
12
+ const figure_public_js_1 = require("../service/figure/figure-public.js");
12
13
  /**
13
14
  * A mesh part's file, by its hash (ADR-0129 decision 8): what the modeller and a board draw a mesh part from, on the
14
15
  * server that authored it. A board server serves its own copy at `/figure-library-file/:hash`.
@@ -32,7 +33,9 @@ async function sendFigureMesh(context, next, hash, domain, files = (0, figure_th
32
33
  context.status = 404;
33
34
  return;
34
35
  }
35
- const kept = await (0, figure_mesh_store_js_1.meshAttachmentOf)({ getRepository: shell_1.getRepository }, [domain.id, domain.parentId].filter(Boolean), hash);
36
+ /* Its own or its parent's, or kept by a domain whose public figure's releases draw from it (G2). */
37
+ const kept = (await (0, figure_mesh_store_js_1.meshAttachmentOf)({ getRepository: shell_1.getRepository }, [domain.id, domain.parentId].filter(Boolean), hash)) ??
38
+ (await (0, figure_mesh_store_js_1.meshAttachmentOf)({ getRepository: shell_1.getRepository }, await (0, figure_public_js_1.publicDomainsOfFile)(hash, domain), hash));
36
39
  if (!kept) {
37
40
  context.status = 404;
38
41
  return;
@@ -1 +1 @@
1
- {"version":3,"file":"figure-mesh-router.js","sourceRoot":"","sources":["../../server/routers/figure-mesh-router.ts"],"names":[],"mappings":";;;AA6BA,wCA6BC;;AA1DD,oEAA+B;AAE/B,yDAA2D;AAC3D,iDAAqD;AAErD,iFAAyE;AACzE,mEAA8D;AAC9D,2FAAiG;AAEjG;;;;;;GAMG;AACU,QAAA,gBAAgB,GAAG,IAAI,oBAAM,EAAE,CAAA;AAE5C,wBAAgB,CAAC,GAAG,CAAC,oBAAoB,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;IACjE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,oBAAoB,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;IACtE,MAAM,OAAO,GAAG,MAAM,IAAA,2BAAe,EAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,oBAAoB,CAAC,CAAA;IAC5I,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;QACpB,OAAM;IACR,CAAC;IACD,MAAM,cAAc,CAAC,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;AAClE,CAAC,CAAC,CAAA;AAEF,qGAAqG;AAC9F,KAAK,UAAU,cAAc,CAClC,OAAY,EACZ,IAAwB,EACxB,IAAY,EACZ,MAAyC,EACzC,QAAwB,IAAA,0CAAc,GAAE;IAExC,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACjC,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;QACpB,OAAM;IACR,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,IAAA,uCAAgB,EAAC,EAAE,aAAa,EAAb,qBAAa,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAa,EAAE,IAAI,CAAC,CAAA;IACtH,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;QACpB,OAAM;IACR,CAAC;IACD,MAAM,IAAI,GAAG,GAAG,EAAE;QAChB,OAAO,CAAC,IAAI,GAAG,4BAAY,CAAA;QAC3B,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,sCAAsC,CAAC,CAAA;QACpE,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,IAAI,GAAG,CAAC,CAAA;IAClC,CAAC,CAAA;IACD,IAAI,EAAE,CAAA;IACN,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;IACpB,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QAClB,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;QACpB,OAAM;IACR,CAAC;IACD,MAAM,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,IAAK,EAAE,IAAI,CAAC,CAAA;IAC3C,IAAI,EAAE,CAAA;AACR,CAAC","sourcesContent":["import Router from 'koa-router'\n\nimport { checkPermission } from '@things-factory/auth-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { meshAttachmentOf } from '../service/figure/figure-mesh-store.js'\nimport { GLB_MIMETYPE } from '../service/figure/figure-glb.js'\nimport { thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'\n\n/**\n * A mesh part's file, by its hash (ADR-0129 decision 8): what the modeller and a board draw a mesh part from, on the\n * server that authored it. A board server serves its own copy at `/figure-library-file/:hash`.\n *\n * The same door as a figure query, and the same scope: this domain's files and its parent's, whose figures it reads.\n * The content is its hash, so it never changes: cached privately for as long as a browser keeps anything.\n */\nexport const figureMeshRouter = new Router()\n\nfigureMeshRouter.get('/figure-mesh/:hash', async (context, next) => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n const allowed = await checkPermission({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges)\n if (!allowed) {\n context.status = 403\n return\n }\n await sendFigureMesh(context, next, context.params.hash, domain)\n})\n\n/** The file of a mesh part the caller may read. Exported for tests, which give their own `files`. */\nexport async function sendFigureMesh(\n context: any,\n next: () => Promise<any>,\n hash: string,\n domain: { id: string; parentId?: string },\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<void> {\n if (!/^[0-9a-f]{64}$/.test(hash)) {\n context.status = 404\n return\n }\n const kept = await meshAttachmentOf({ getRepository }, [domain.id, domain.parentId].filter(Boolean) as string[], hash)\n if (!kept) {\n context.status = 404\n return\n }\n const ours = () => {\n context.type = GLB_MIMETYPE\n context.set('Cache-Control', 'private, max-age=31536000, immutable')\n context.set('ETag', `\"${hash}\"`)\n }\n ours()\n context.status = 200\n if (context.fresh) {\n context.status = 304\n return\n }\n await files.send(context, kept.path!, next)\n ours()\n}\n"]}
1
+ {"version":3,"file":"figure-mesh-router.js","sourceRoot":"","sources":["../../server/routers/figure-mesh-router.ts"],"names":[],"mappings":";;;AA8BA,wCAgCC;;AA9DD,oEAA+B;AAE/B,yDAA2D;AAC3D,iDAAqD;AAErD,iFAAyE;AACzE,mEAA8D;AAC9D,2FAAiG;AACjG,yEAAwE;AAExE;;;;;;GAMG;AACU,QAAA,gBAAgB,GAAG,IAAI,oBAAM,EAAE,CAAA;AAE5C,wBAAgB,CAAC,GAAG,CAAC,oBAAoB,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;IACjE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,oBAAoB,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;IACtE,MAAM,OAAO,GAAG,MAAM,IAAA,2BAAe,EAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,oBAAoB,CAAC,CAAA;IAC5I,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;QACpB,OAAM;IACR,CAAC;IACD,MAAM,cAAc,CAAC,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;AAClE,CAAC,CAAC,CAAA;AAEF,qGAAqG;AAC9F,KAAK,UAAU,cAAc,CAClC,OAAY,EACZ,IAAwB,EACxB,IAAY,EACZ,MAAyC,EACzC,QAAwB,IAAA,0CAAc,GAAE;IAExC,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACjC,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;QACpB,OAAM;IACR,CAAC;IACD,oGAAoG;IACpG,MAAM,IAAI,GACR,CAAC,MAAM,IAAA,uCAAgB,EAAC,EAAE,aAAa,EAAb,qBAAa,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAa,EAAE,IAAI,CAAC,CAAC;QAC3G,CAAC,MAAM,IAAA,uCAAgB,EAAC,EAAE,aAAa,EAAb,qBAAa,EAAE,EAAE,MAAM,IAAA,sCAAmB,EAAC,IAAI,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAA;IAC5F,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;QACpB,OAAM;IACR,CAAC;IACD,MAAM,IAAI,GAAG,GAAG,EAAE;QAChB,OAAO,CAAC,IAAI,GAAG,4BAAY,CAAA;QAC3B,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,sCAAsC,CAAC,CAAA;QACpE,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,IAAI,GAAG,CAAC,CAAA;IAClC,CAAC,CAAA;IACD,IAAI,EAAE,CAAA;IACN,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;IACpB,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QAClB,OAAO,CAAC,MAAM,GAAG,GAAG,CAAA;QACpB,OAAM;IACR,CAAC;IACD,MAAM,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,IAAK,EAAE,IAAI,CAAC,CAAA;IAC3C,IAAI,EAAE,CAAA;AACR,CAAC","sourcesContent":["import Router from 'koa-router'\n\nimport { checkPermission } from '@things-factory/auth-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { meshAttachmentOf } from '../service/figure/figure-mesh-store.js'\nimport { GLB_MIMETYPE } from '../service/figure/figure-glb.js'\nimport { thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'\nimport { publicDomainsOfFile } from '../service/figure/figure-public.js'\n\n/**\n * A mesh part's file, by its hash (ADR-0129 decision 8): what the modeller and a board draw a mesh part from, on the\n * server that authored it. A board server serves its own copy at `/figure-library-file/:hash`.\n *\n * The same door as a figure query, and the same scope: this domain's files and its parent's, whose figures it reads.\n * The content is its hash, so it never changes: cached privately for as long as a browser keeps anything.\n */\nexport const figureMeshRouter = new Router()\n\nfigureMeshRouter.get('/figure-mesh/:hash', async (context, next) => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n const allowed = await checkPermission({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges)\n if (!allowed) {\n context.status = 403\n return\n }\n await sendFigureMesh(context, next, context.params.hash, domain)\n})\n\n/** The file of a mesh part the caller may read. Exported for tests, which give their own `files`. */\nexport async function sendFigureMesh(\n context: any,\n next: () => Promise<any>,\n hash: string,\n domain: { id: string; parentId?: string },\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<void> {\n if (!/^[0-9a-f]{64}$/.test(hash)) {\n context.status = 404\n return\n }\n /* Its own or its parent's, or kept by a domain whose public figure's releases draw from it (G2). */\n const kept =\n (await meshAttachmentOf({ getRepository }, [domain.id, domain.parentId].filter(Boolean) as string[], hash)) ??\n (await meshAttachmentOf({ getRepository }, await publicDomainsOfFile(hash, domain), hash))\n if (!kept) {\n context.status = 404\n return\n }\n const ours = () => {\n context.type = GLB_MIMETYPE\n context.set('Cache-Control', 'private, max-age=31536000, immutable')\n context.set('ETag', `\"${hash}\"`)\n }\n ours()\n context.status = 200\n if (context.fresh) {\n context.status = 304\n return\n }\n await files.send(context, kept.path!, next)\n ours()\n}\n"]}
@@ -4,13 +4,12 @@ exports.figureThumbnailRouter = void 0;
4
4
  exports.sendFigureThumbnail = sendFigureThumbnail;
5
5
  const tslib_1 = require("tslib");
6
6
  const koa_router_1 = tslib_1.__importDefault(require("koa-router"));
7
- const typeorm_1 = require("typeorm");
8
7
  const auth_base_1 = require("@things-factory/auth-base");
9
8
  const shell_1 = require("@things-factory/shell");
10
9
  const fs_1 = require("fs");
11
- const figure_js_1 = require("../service/figure/figure.js");
12
10
  const figure_thumbnail_store_js_1 = require("../service/figure/figure-thumbnail-store.js");
13
11
  const figure_recipe_templates_js_1 = require("../service/figure-recipe/figure-recipe-templates.js");
12
+ const figure_public_js_1 = require("../service/figure/figure-public.js");
14
13
  /**
15
14
  * 썸네일을 **그림으로** 내보내는 길.
16
15
  *
@@ -64,11 +63,8 @@ async function sendFigureThumbnail(context, next, id, domain, files = (0, figure
64
63
  읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고
65
64
  그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.
66
65
  */
67
- const found = await (0, shell_1.getRepository)(figure_js_1.Figure).findOne({
68
- where: { domain: { id: (0, typeorm_1.In)([domain.id, domain.parentId].filter(Boolean)) }, id },
69
- relations: { domain: true },
70
- select: { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, domain: { id: true } }
71
- });
66
+ /* Another domain's public figure is read too (G2): its card is what a reader chooses it by. */
67
+ const found = await (0, figure_public_js_1.readableFigure)(domain, id, { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, public: true, domain: { id: true } });
72
68
  if (!found?.thumbnailHash) {
73
69
  context.status = 404;
74
70
  return;
@@ -1 +1 @@
1
- {"version":3,"file":"figure-thumbnail-router.js","sourceRoot":"","sources":["../../server/routers/figure-thumbnail-router.ts"],"names":[],"mappings":"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","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 { readFileSync } from 'fs'\n\nimport { Figure } from '../service/figure/figure.js'\nimport { thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'\nimport { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'\n\n/**\n * 썸네일을 **그림으로** 내보내는 길.\n *\n * ## 왜 필요한가\n *\n * 썸네일은 attachment-base 가 내용 해시로 갖는다(ADR-0104 결정 6). 옛날에는 base64 문자열로 칸에 있었고, 그것을 목록 질의가\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 * 말한 형식을 attachment 의 mimetype 으로 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.\n */\nexport const figureThumbnailRouter = new Router()\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 await sendFigureThumbnail(context, next, context.params.id, domain)\n})\n\n/**\n * The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give\n * their own `files`.\n */\nexport async function sendFigureThumbnail(\n context: any,\n next: () => Promise<any>,\n id: string,\n domain: { id: string; parentId?: string },\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<void> {\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 relations: { domain: true },\n select: { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, domain: { id: true } }\n })\n if (!found?.thumbnailHash) {\n context.status = 404\n return\n }\n\n /*\n 그림은 **그 figure 의 도메인**에서 찾는다 — 묻는 도메인이 아니다(판정 조건 4). 해시가 같아도 다른 도메인의 행을\n 집지 않는다. 물려받은 figure 의 그림은 부모 도메인에 있다.\n */\n const picture = await thumbnailAttachmentOf({ getRepository }, found.domain!.id, found.thumbnailHash)\n if (!picture) {\n context.status = 404\n return\n }\n\n /*\n **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면 안 된다 —\n attachment 의 공개 경로가 쓰는 `public` 을 따라 쓰지 않는다(판정 조건 1). 같은 내용은 같은 해시이므로 ETag 가\n 곧 내용이다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않는다.\n */\n const ours = () => {\n context.type = picture.mimetype\n context.set('Cache-Control', 'private, max-age=31536000')\n context.set('ETag', `\"${found.thumbnailHash}\"`)\n if (found.thumbnailUpdatedAt) context.set('Last-Modified', new Date(found.thumbnailUpdatedAt).toUTCString())\n }\n ours()\n /* koa answers `fresh` only for a 2xx: before a body the status is still 404, and the check never matched. */\n context.status = 200\n if (context.fresh) {\n context.status = 304\n return\n }\n await files.send(context, picture.path!, next)\n /* The storage sets its own (S3 its ETag, attachment's Cache-Control): this door's are said last. */\n ours()\n}\n\n/**\n * A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the\n * templates themselves. The file changes only with a release, so a day's private cache is enough.\n */\nfigureThumbnailRouter.get('/figure-recipe-thumbnail/:name', async context => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n const allowed = await checkPermission({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges)\n if (!allowed) {\n context.status = 403\n return\n }\n const template = recipeTemplates().templates.find(t => t.recipe.name === context.params.name)\n if (!template?.thumbnail) {\n context.status = 404\n return\n }\n context.type = 'image/png'\n context.set('Cache-Control', 'private, max-age=86400')\n context.body = readFileSync(template.thumbnail)\n})\n"]}
1
+ {"version":3,"file":"figure-thumbnail-router.js","sourceRoot":"","sources":["../../server/routers/figure-thumbnail-router.ts"],"names":[],"mappings":"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","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 { readFileSync } from 'fs'\n\nimport { Figure } from '../service/figure/figure.js'\nimport { thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'\nimport { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'\nimport { readableFigure } from '../service/figure/figure-public.js'\n\n/**\n * 썸네일을 **그림으로** 내보내는 길.\n *\n * ## 왜 필요한가\n *\n * 썸네일은 attachment-base 가 내용 해시로 갖는다(ADR-0104 결정 6). 옛날에는 base64 문자열로 칸에 있었고, 그것을 목록 질의가\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 * 말한 형식을 attachment 의 mimetype 으로 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.\n */\nexport const figureThumbnailRouter = new Router()\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 await sendFigureThumbnail(context, next, context.params.id, domain)\n})\n\n/**\n * The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give\n * their own `files`.\n */\nexport async function sendFigureThumbnail(\n context: any,\n next: () => Promise<any>,\n id: string,\n domain: { id: string; parentId?: string },\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<void> {\n /*\n 읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고\n 그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.\n */\n /* Another domain's public figure is read too (G2): its card is what a reader chooses it by. */\n const found = await readableFigure(domain, id, { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, public: true, domain: { id: true } })\n if (!found?.thumbnailHash) {\n context.status = 404\n return\n }\n\n /*\n 그림은 **그 figure 의 도메인**에서 찾는다 — 묻는 도메인이 아니다(판정 조건 4). 해시가 같아도 다른 도메인의 행을\n 집지 않는다. 물려받은 figure 의 그림은 부모 도메인에 있다.\n */\n const picture = await thumbnailAttachmentOf({ getRepository }, found.domain!.id, found.thumbnailHash)\n if (!picture) {\n context.status = 404\n return\n }\n\n /*\n **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면 안 된다 —\n attachment 의 공개 경로가 쓰는 `public` 을 따라 쓰지 않는다(판정 조건 1). 같은 내용은 같은 해시이므로 ETag 가\n 곧 내용이다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않는다.\n */\n const ours = () => {\n context.type = picture.mimetype\n context.set('Cache-Control', 'private, max-age=31536000')\n context.set('ETag', `\"${found.thumbnailHash}\"`)\n if (found.thumbnailUpdatedAt) context.set('Last-Modified', new Date(found.thumbnailUpdatedAt).toUTCString())\n }\n ours()\n /* koa answers `fresh` only for a 2xx: before a body the status is still 404, and the check never matched. */\n context.status = 200\n if (context.fresh) {\n context.status = 304\n return\n }\n await files.send(context, picture.path!, next)\n /* The storage sets its own (S3 its ETag, attachment's Cache-Control): this door's are said last. */\n ours()\n}\n\n/**\n * A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the\n * templates themselves. The file changes only with a release, so a day's private cache is enough.\n */\nfigureThumbnailRouter.get('/figure-recipe-thumbnail/:name', async context => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n const allowed = await checkPermission({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges)\n if (!allowed) {\n context.status = 403\n return\n }\n const template = recipeTemplates().templates.find(t => t.recipe.name === context.params.name)\n if (!template?.thumbnail) {\n context.status = 404\n return\n }\n context.type = 'image/png'\n context.set('Cache-Control', 'private, max-age=86400')\n context.body = readFileSync(template.thumbnail)\n})\n"]}
@@ -0,0 +1,10 @@
1
+ /** Registers the categories this server allows. Called once at the top of the app's server module. */
2
+ export declare function registerFigureCategories(codes: readonly string[]): void;
3
+ /** The categories registered, or null when a category is free text. */
4
+ export declare function figureCategories(): readonly string[] | null;
5
+ /** For tests: back to free text. */
6
+ export declare function clearFigureCategories(): void;
7
+ /** A category being set on a figure: refused outside the vocabulary, and when the figure already has another. */
8
+ export declare function assertFigureCategory(next: string | null | undefined, current?: string | null): void;
9
+ /** A figure going out: with a vocabulary registered it carries one of its categories. */
10
+ export declare function assertReleasableCategory(category: string | null | undefined, type: string): void;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerFigureCategories = registerFigureCategories;
4
+ exports.figureCategories = figureCategories;
5
+ exports.clearFigureCategories = clearFigureCategories;
6
+ exports.assertFigureCategory = assertFigureCategory;
7
+ exports.assertReleasableCategory = assertReleasableCategory;
8
+ const shell_1 = require("@things-factory/shell");
9
+ /*
10
+ * The categories an app allows (LV figure-server design G5). Without a registration a category is free text, as
11
+ * operato-figure has it. An app that registers its vocabulary -- the LV figure-server: EQUIPMENT, CARRIER, UTILITY,
12
+ * COMPONENT -- gets three rules: a category outside it is refused, a category once set is not changed, and a figure
13
+ * is not released without one. The vocabulary is the app's; this module does not know what the words mean.
14
+ */
15
+ let registered = null;
16
+ /** Registers the categories this server allows. Called once at the top of the app's server module. */
17
+ function registerFigureCategories(codes) {
18
+ const cleaned = codes.map(code => (typeof code === 'string' ? code.trim() : ''));
19
+ if (!cleaned.length || cleaned.some(code => !code) || new Set(cleaned).size !== cleaned.length) {
20
+ throw new Error(`registerFigureCategories needs distinct non-empty codes, not ${JSON.stringify(codes)}.`);
21
+ }
22
+ registered = Object.freeze([...cleaned]);
23
+ }
24
+ /** The categories registered, or null when a category is free text. */
25
+ function figureCategories() {
26
+ return registered;
27
+ }
28
+ /** For tests: back to free text. */
29
+ function clearFigureCategories() {
30
+ registered = null;
31
+ }
32
+ /** A category being set on a figure: refused outside the vocabulary, and when the figure already has another. */
33
+ function assertFigureCategory(next, current) {
34
+ if (!registered || next === undefined)
35
+ return;
36
+ const wanted = (next ?? '').trim();
37
+ const had = (current ?? '').trim();
38
+ if (had && wanted !== had) {
39
+ throw new shell_1.Refusal('figure-category-fixed', { category: had }, 'the category {category} is set once and does not change.');
40
+ }
41
+ if (wanted && !registered.includes(wanted)) {
42
+ throw new shell_1.Refusal('figure-category-unknown', { category: wanted, allowed: registered.join(', ') }, '{category} is not a category here. use one of {allowed}.');
43
+ }
44
+ }
45
+ /** A figure going out: with a vocabulary registered it carries one of its categories. */
46
+ function assertReleasableCategory(category, type) {
47
+ if (!registered)
48
+ return;
49
+ if (!(category ?? '').trim()) {
50
+ throw new shell_1.Refusal('figure-category-missing', { type, allowed: registered.join(', ') }, '{type} has no category. choose one of {allowed} before releasing.');
51
+ }
52
+ }
53
+ //# sourceMappingURL=figure-categories.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-categories.js","sourceRoot":"","sources":["../../../server/service/figure/figure-categories.ts"],"names":[],"mappings":";;AAYA,4DAMC;AAGD,4CAEC;AAGD,sDAEC;AAGD,oDAUC;AAGD,4DAKC;AAjDD,iDAA+C;AAE/C;;;;;GAKG;AAEH,IAAI,UAAU,GAA6B,IAAI,CAAA;AAE/C,sGAAsG;AACtG,SAAgB,wBAAwB,CAAC,KAAwB;IAC/D,MAAM,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;IAChF,IAAI,CAAC,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC;QAC/F,MAAM,IAAI,KAAK,CAAC,gEAAgE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IAC3G,CAAC;IACD,UAAU,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAA;AAC1C,CAAC;AAED,uEAAuE;AACvE,SAAgB,gBAAgB;IAC9B,OAAO,UAAU,CAAA;AACnB,CAAC;AAED,oCAAoC;AACpC,SAAgB,qBAAqB;IACnC,UAAU,GAAG,IAAI,CAAA;AACnB,CAAC;AAED,iHAAiH;AACjH,SAAgB,oBAAoB,CAAC,IAA+B,EAAE,OAAuB;IAC3F,IAAI,CAAC,UAAU,IAAI,IAAI,KAAK,SAAS;QAAE,OAAM;IAC7C,MAAM,MAAM,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;IAClC,MAAM,GAAG,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;IAClC,IAAI,GAAG,IAAI,MAAM,KAAK,GAAG,EAAE,CAAC;QAC1B,MAAM,IAAI,eAAO,CAAC,uBAAuB,EAAE,EAAE,QAAQ,EAAE,GAAG,EAAE,EAAE,0DAA0D,CAAC,CAAA;IAC3H,CAAC;IACD,IAAI,MAAM,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC3C,MAAM,IAAI,eAAO,CAAC,yBAAyB,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,0DAA0D,CAAC,CAAA;IAChK,CAAC;AACH,CAAC;AAED,yFAAyF;AACzF,SAAgB,wBAAwB,CAAC,QAAmC,EAAE,IAAY;IACxF,IAAI,CAAC,UAAU;QAAE,OAAM;IACvB,IAAI,CAAC,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;QAC7B,MAAM,IAAI,eAAO,CAAC,yBAAyB,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,mEAAmE,CAAC,CAAA;IAC7J,CAAC;AACH,CAAC","sourcesContent":["import { Refusal } from '@things-factory/shell'\n\n/*\n * The categories an app allows (LV figure-server design G5). Without a registration a category is free text, as\n * operato-figure has it. An app that registers its vocabulary -- the LV figure-server: EQUIPMENT, CARRIER, UTILITY,\n * COMPONENT -- gets three rules: a category outside it is refused, a category once set is not changed, and a figure\n * is not released without one. The vocabulary is the app's; this module does not know what the words mean.\n */\n\nlet registered: readonly string[] | null = null\n\n/** Registers the categories this server allows. Called once at the top of the app's server module. */\nexport function registerFigureCategories(codes: readonly string[]): void {\n const cleaned = codes.map(code => (typeof code === 'string' ? code.trim() : ''))\n if (!cleaned.length || cleaned.some(code => !code) || new Set(cleaned).size !== cleaned.length) {\n throw new Error(`registerFigureCategories needs distinct non-empty codes, not ${JSON.stringify(codes)}.`)\n }\n registered = Object.freeze([...cleaned])\n}\n\n/** The categories registered, or null when a category is free text. */\nexport function figureCategories(): readonly string[] | null {\n return registered\n}\n\n/** For tests: back to free text. */\nexport function clearFigureCategories(): void {\n registered = null\n}\n\n/** A category being set on a figure: refused outside the vocabulary, and when the figure already has another. */\nexport function assertFigureCategory(next: string | null | undefined, current?: string | null): void {\n if (!registered || next === undefined) return\n const wanted = (next ?? '').trim()\n const had = (current ?? '').trim()\n if (had && wanted !== had) {\n throw new Refusal('figure-category-fixed', { category: had }, 'the category {category} is set once and does not change.')\n }\n if (wanted && !registered.includes(wanted)) {\n throw new Refusal('figure-category-unknown', { category: wanted, allowed: registered.join(', ') }, '{category} is not a category here. use one of {allowed}.')\n }\n}\n\n/** A figure going out: with a vocabulary registered it carries one of its categories. */\nexport function assertReleasableCategory(category: string | null | undefined, type: string): void {\n if (!registered) return\n if (!(category ?? '').trim()) {\n throw new Refusal('figure-category-missing', { type, allowed: registered.join(', ') }, '{type} has no category. choose one of {allowed} before releasing.')\n }\n}\n"]}
@@ -37,6 +37,21 @@ export interface Inspection {
37
37
  /** Whether anything blocks. The screen locks the button with this. */
38
38
  blocked: boolean;
39
39
  }
40
+ /** What an app's release check is shown: the figure's asset, its row's type and category, and the reader's words. */
41
+ export interface FigureReleaseCheckInput {
42
+ asset: FigureAsset;
43
+ type?: string;
44
+ category?: string | null;
45
+ t?: Translate;
46
+ }
47
+ /**
48
+ * A check an app adds to releasing (LV figure-server design G10): findings in the gate's own shape, blocking or said.
49
+ * The LV figure-server refuses an origin off the middle of the room, which operato-figure only says (ADR-0104
50
+ * decision 4: refusing is the receiving side's), and says its carrier and budget advice.
51
+ */
52
+ export type FigureReleaseCheck = (input: FigureReleaseCheckInput) => GateFinding[];
53
+ /** Adds a check to every release verdict (`inspectFigure`, `releaseFigure`). Returns the way to take it off again. */
54
+ export declare function registerFigureReleaseCheck(check: FigureReleaseCheck): () => void;
40
55
  /**
41
56
  * Validates a source and computes what the catalogue shows: the cost from `figureCostOf`, and the score from `figureScoreOf`
42
57
  * -- the headroom against placing `LIMITS.screenInstances` of it on one screen (chief architect, 2026-09-24), the
@@ -57,7 +72,9 @@ export declare function parseSource(sourceJson: string): ParsedSource;
57
72
  export type Translate = (key: string, options?: Record<string, unknown>) => string;
58
73
  export declare function inspectSource(sourceJson?: string | null, releasedJson?: string | null, t?: Translate,
59
74
  /** The row's `type` -- the only place the name lives, since the asset carries none. */
60
- rowType?: string): Inspection;
75
+ rowType?: string,
76
+ /** The row's category, for an app's release checks (G10). */
77
+ rowCategory?: string | null): Inspection;
61
78
  /**
62
79
  * What a release records about itself (ADR-0104 decision 5): the format its asset is written in, what it measured
63
80
  * (its cost at its largest, whether resizing is stretching), its plan from above, and what changed against the previous release -- the kernel's own comparison, kept as it was then so a consumer that pins releases
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FigureSourceError = exports.FIGURE_ASSET_VERSION = void 0;
4
+ exports.registerFigureReleaseCheck = registerFigureReleaseCheck;
4
5
  exports.parseSource = parseSource;
5
6
  exports.inspectSource = inspectSource;
6
7
  exports.releaseRecordOf = releaseRecordOf;
@@ -22,6 +23,29 @@ class FigureSourceError extends Error {
22
23
  }
23
24
  }
24
25
  exports.FigureSourceError = FigureSourceError;
26
+ const releaseChecks = [];
27
+ /** Adds a check to every release verdict (`inspectFigure`, `releaseFigure`). Returns the way to take it off again. */
28
+ function registerFigureReleaseCheck(check) {
29
+ releaseChecks.push(check);
30
+ return () => {
31
+ const at = releaseChecks.indexOf(check);
32
+ if (at >= 0)
33
+ releaseChecks.splice(at, 1);
34
+ };
35
+ }
36
+ /** The app's checks on an asset the kernel passed. A check that throws blocks, by name: a check is never skipped. */
37
+ function appFindings(input) {
38
+ const found = [];
39
+ for (const check of releaseChecks) {
40
+ try {
41
+ found.push(...check(input));
42
+ }
43
+ catch (e) {
44
+ found.push({ code: 'release-check-failed', message: `a release check failed: ${e?.message ?? String(e)}`, blocking: true });
45
+ }
46
+ }
47
+ return found;
48
+ }
25
49
  /**
26
50
  * That the source is JSON and names the format.
27
51
  *
@@ -165,7 +189,9 @@ function sayerOf(t) {
165
189
  }
166
190
  function inspectSource(sourceJson, releasedJson, t,
167
191
  /** The row's `type` -- the only place the name lives, since the asset carries none. */
168
- rowType) {
192
+ rowType,
193
+ /** The row's category, for an app's release checks (G10). */
194
+ rowCategory) {
169
195
  if (!sourceJson) {
170
196
  return { blocked: true, findings: [{ code: 'no-source', message: '저장된 정본이 없다. 저장하면 발행할 수 있다', blocking: true }] };
171
197
  }
@@ -247,6 +273,7 @@ rowType) {
247
273
  }
248
274
  if (releasedJson)
249
275
  findings.push(...figureContractFindings(asset, releasedJson, say));
276
+ findings.push(...appFindings({ asset: asset, type: rowType, category: rowCategory, t }));
250
277
  return { blocked: findings.some(finding => finding.blocking), findings };
251
278
  }
252
279
  /**