@things-factory/shell 10.1.37 → 10.1.38

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.
@@ -9,3 +9,4 @@ export * from './keyset-cursor.js';
9
9
  export * from './list-param-adjuster.js';
10
10
  export * from './get-times-for-period.js';
11
11
  export * from './headless-pool/index.js';
12
+ export * from './subscribe-auth.js';
@@ -12,4 +12,5 @@ tslib_1.__exportStar(require("./keyset-cursor.js"), exports);
12
12
  tslib_1.__exportStar(require("./list-param-adjuster.js"), exports);
13
13
  tslib_1.__exportStar(require("./get-times-for-period.js"), exports);
14
14
  tslib_1.__exportStar(require("./headless-pool/index.js"), exports);
15
+ tslib_1.__exportStar(require("./subscribe-auth.js"), exports);
15
16
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/utils/index.ts"],"names":[],"mappings":";;;AAAA,0DAA+B;AAC/B,mEAAwC;AACxC,iEAAsC;AACtC,kEAAuC;AACvC,qEAA0C;AAC1C,gEAAqC;AACrC,kFAAuD;AACvD,6DAAkC;AAClC,mEAAwC;AACxC,oEAAyC;AACzC,mEAAwC","sourcesContent":["export * from './get-domain.js'\nexport * from './apply-domain-filter.js'\nexport * from './condition-builder.js'\nexport * from './list-query-builder.js'\nexport * from './list-params-converter.js'\nexport * from './publish-progress.js'\nexport * from './get-query-builder-from-list-params.js'\nexport * from './keyset-cursor.js'\nexport * from './list-param-adjuster.js'\nexport * from './get-times-for-period.js'\nexport * from './headless-pool/index.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/utils/index.ts"],"names":[],"mappings":";;;AAAA,0DAA+B;AAC/B,mEAAwC;AACxC,iEAAsC;AACtC,kEAAuC;AACvC,qEAA0C;AAC1C,gEAAqC;AACrC,kFAAuD;AACvD,6DAAkC;AAClC,mEAAwC;AACxC,oEAAyC;AACzC,mEAAwC;AACxC,8DAAmC","sourcesContent":["export * from './get-domain.js'\nexport * from './apply-domain-filter.js'\nexport * from './condition-builder.js'\nexport * from './list-query-builder.js'\nexport * from './list-params-converter.js'\nexport * from './publish-progress.js'\nexport * from './get-query-builder-from-list-params.js'\nexport * from './keyset-cursor.js'\nexport * from './list-param-adjuster.js'\nexport * from './get-times-for-period.js'\nexport * from './headless-pool/index.js'\nexport * from './subscribe-auth.js'\n"]}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 구독 허용 여부 단언. 불가 시 throw (graphql-ws 가 구독 거부).
3
+ * - domain 없음 → 거부.
4
+ * - user 가 해당 subdomain 의 도메인 소속이 아니고 superUser 도 아니면 → 거부.
5
+ *
6
+ * 주의(버그 수정): process.superUserGranted 는 async(Promise<boolean>) 다. 기존 양쪽 spine 은
7
+ * `!process.superUserGranted(...)` 로 동기 평가해 `!Promise` = 항상 false → membership throw 가
8
+ * 발동하지 않는 fail-open 상태였다. 여기서는 await 로 제대로 평가한다 → subscribe 는 async.
9
+ *
10
+ * @param domain context.state.domain
11
+ * @param user context.state.user (domains[] / email 사용)
12
+ * @param isSuperUser (domain, user) → boolean | Promise<boolean> (process.superUserGranted 주입)
13
+ */
14
+ export declare function assertDomainSubscribeAllowed(domain: any, user: any, isSuperUser: (domain: any, user: any) => boolean | Promise<boolean>): Promise<void>;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ /*
3
+ * 구독 도메인 인증 게이트 — 순수 로직 (데코레이터·pubsub 없음 → 테스트 가능).
4
+ *
5
+ * 실시간 구독(@Subscription)은 같은 도메인 구성원만 허용해야 한다(도메인 격리 = 보안 핵심).
6
+ * 셸의 `data` 구독 · ai-assistant 의 chat-message · session-activity · board-ai 의 board-patch ·
7
+ * headless-twin 의 구독이 이 하나를 부른다(reuse — fork 금지). 가장 낮은 층에 둔다 — 부르는 쪽이 모두
8
+ * 이미 셸 위에 선다(2026-09-24 ai-assistant 에서 옮김). superUserGranted 는 process 전역 의존이라
9
+ * 주입받아 순수성 유지.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.assertDomainSubscribeAllowed = assertDomainSubscribeAllowed;
13
+ /**
14
+ * 구독 허용 여부 단언. 불가 시 throw (graphql-ws 가 구독 거부).
15
+ * - domain 없음 → 거부.
16
+ * - user 가 해당 subdomain 의 도메인 소속이 아니고 superUser 도 아니면 → 거부.
17
+ *
18
+ * 주의(버그 수정): process.superUserGranted 는 async(Promise<boolean>) 다. 기존 양쪽 spine 은
19
+ * `!process.superUserGranted(...)` 로 동기 평가해 `!Promise` = 항상 false → membership throw 가
20
+ * 발동하지 않는 fail-open 상태였다. 여기서는 await 로 제대로 평가한다 → subscribe 는 async.
21
+ *
22
+ * @param domain context.state.domain
23
+ * @param user context.state.user (domains[] / email 사용)
24
+ * @param isSuperUser (domain, user) → boolean | Promise<boolean> (process.superUserGranted 주입)
25
+ */
26
+ async function assertDomainSubscribeAllowed(domain, user, isSuperUser) {
27
+ if (!domain) {
28
+ throw new Error('domain required');
29
+ }
30
+ const subdomain = domain?.subdomain;
31
+ if (!user?.domains?.find((d) => d.subdomain === subdomain) && !(await isSuperUser(domain, user))) {
32
+ throw new Error(`domain(${subdomain}) is not working for user(${user?.email}).`);
33
+ }
34
+ }
35
+ //# sourceMappingURL=subscribe-auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscribe-auth.js","sourceRoot":"","sources":["../../server/utils/subscribe-auth.ts"],"names":[],"mappings":";AAAA;;;;;;;;GAQG;;AAeH,oEAYC;AAzBD;;;;;;;;;;;;GAYG;AACI,KAAK,UAAU,4BAA4B,CAChD,MAAW,EACX,IAAS,EACT,WAAmE;IAEnE,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,CAAA;IACpC,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,EAAE,SAAS,CAAA;IACnC,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,SAAS,CAAC,IAAI,CAAC,CAAC,MAAM,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC;QACtG,MAAM,IAAI,KAAK,CAAC,UAAU,SAAS,6BAA6B,IAAI,EAAE,KAAK,IAAI,CAAC,CAAA;IAClF,CAAC;AACH,CAAC","sourcesContent":["/*\n * 구독 도메인 인증 게이트 — 순수 로직 (데코레이터·pubsub 없음 → 테스트 가능).\n *\n * 실시간 구독(@Subscription)은 같은 도메인 구성원만 허용해야 한다(도메인 격리 = 보안 핵심).\n * 셸의 `data` 구독 · ai-assistant 의 chat-message · session-activity · board-ai 의 board-patch ·\n * headless-twin 의 구독이 이 하나를 부른다(reuse — fork 금지). 가장 낮은 층에 둔다 — 부르는 쪽이 모두\n * 이미 셸 위에 선다(2026-09-24 ai-assistant 에서 옮김). superUserGranted 는 process 전역 의존이라\n * 주입받아 순수성 유지.\n */\n\n/**\n * 구독 허용 여부 단언. 불가 시 throw (graphql-ws 가 구독 거부).\n * - domain 없음 → 거부.\n * - user 가 해당 subdomain 의 도메인 소속이 아니고 superUser 도 아니면 → 거부.\n *\n * 주의(버그 수정): process.superUserGranted 는 async(Promise<boolean>) 다. 기존 양쪽 spine 은\n * `!process.superUserGranted(...)` 로 동기 평가해 `!Promise` = 항상 false → membership throw 가\n * 발동하지 않는 fail-open 상태였다. 여기서는 await 로 제대로 평가한다 → subscribe 는 async.\n *\n * @param domain context.state.domain\n * @param user context.state.user (domains[] / email 사용)\n * @param isSuperUser (domain, user) → boolean | Promise<boolean> (process.superUserGranted 주입)\n */\nexport async function assertDomainSubscribeAllowed(\n domain: any,\n user: any,\n isSuperUser: (domain: any, user: any) => boolean | Promise<boolean>\n): Promise<void> {\n if (!domain) {\n throw new Error('domain required')\n }\n const subdomain = domain?.subdomain\n if (!user?.domains?.find((d: any) => d.subdomain === subdomain) && !(await isSuperUser(domain, user))) {\n throw new Error(`domain(${subdomain}) is not working for user(${user?.email}).`)\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/shell",
3
- "version": "10.1.37",
3
+ "version": "10.1.38",
4
4
  "description": "Core module for framework",
5
5
  "bin": {
6
6
  "things-factory": "bin/things-factory",
@@ -126,5 +126,5 @@
126
126
  "pg": "^8.7.3",
127
127
  "sqlite3": "^5.0.8"
128
128
  },
129
- "gitHead": "d8a69e5c8803a4b9d3289df0cf38e161a207db61"
129
+ "gitHead": "b947349fe1c27d11038b592ceadaba336f14785b"
130
130
  }
@@ -27,17 +27,15 @@ const PACKAGES = path.join(REPO_ROOT, 'packages')
27
27
 
28
28
  const SKIP = new Set(['node_modules', 'dist', 'dist-client', 'dist-server', 'dist-shared', 'coverage'])
29
29
 
30
- /** Files that still render the CommonPopupStyles `.buttons` row, as of 2026-09-17. */
31
- const STILL_USING = [
32
- 'auth-ui/client/components/app-binding-detail.ts',
33
- 'auth-ui/client/components/appliance-editor.ts',
34
- 'auth-ui/client/components/application-editor.ts',
35
- 'organization/client/component/approval-line-items-editor-popup.ts',
36
- 'organization/client/component/approval-line-selector.ts',
37
- 'organization/client/component/assignees-editor-popup.ts',
38
- 'organization/client/component/department-selector.ts',
39
- 'organization/client/component/recipients-editor-popup.ts'
40
- ]
30
+ /**
31
+ * Files that still render the CommonPopupStyles `.buttons` row.
32
+ *
33
+ * Empty since 2026-09-24: auth-ui's three editors and organization's five popups moved to
34
+ * `<ox-popup-actions>`. The list stays so that a new hand-drawn row is still caught. The `.buttons`
35
+ * rule itself stays in `@operato/styles` - popups outside this repository (dssp, fmsim) still draw
36
+ * with it, and removing it is a decision to take with them.
37
+ */
38
+ const STILL_USING: string[] = []
41
39
 
42
40
  function clientSources(dir: string, found: string[] = []): string[] {
43
41
  for (const entry of readdirSync(dir)) {
@@ -52,7 +50,12 @@ function clientSources(dir: string, found: string[] = []): string[] {
52
50
  }
53
51
 
54
52
  if (stat.isDirectory()) clientSources(full, found)
55
- else if (entry.endsWith('.ts') && full.includes(`${path.sep}client${path.sep}`)) found.push(full)
53
+ /*
54
+ * Tests are skipped: they never render a popup, and a test that guards the row has to name the
55
+ * class it forbids - which this scan would then count as a use (it did, 2026-09-24).
56
+ */
57
+ else if (entry.endsWith('.ts') && !entry.endsWith('.test.ts') && full.includes(`${path.sep}client${path.sep}`))
58
+ found.push(full)
56
59
  }
57
60
  return found
58
61
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 구독 도메인 인증 게이트 — 단위 테스트 (보안 핵심: 도메인 격리).
3
+ * board-patch / chat-message 양쪽 spine 이 공유하는 게이트.
4
+ *
5
+ * 회귀 방지: process.superUserGranted 는 async 다. 동기 `!process.superUserGranted(...)` 로
6
+ * 평가하면 `!Promise` = 항상 false → membership throw 가 죽는다(fail-open). 게이트는 반드시
7
+ * await 로 평가해야 한다. 아래 superUser 분기 테스트가 그 회귀를 박는다.
8
+ */
9
+ import { assertDomainSubscribeAllowed } from '../server/utils/subscribe-auth'
10
+
11
+ const never = async () => false // superUser 아님 (async — 실제 process.superUserGranted 와 동일)
12
+ const always = async () => true // superUser (async)
13
+
14
+ const domain = { id: 'd1', subdomain: 'acme' }
15
+ const userInDomain = { email: 'a@x.io', domains: [{ subdomain: 'acme' }, { subdomain: 'other' }] }
16
+ const userOutDomain = { email: 'b@x.io', domains: [{ subdomain: 'other' }] }
17
+
18
+ describe('assertDomainSubscribeAllowed (도메인 격리 게이트)', () => {
19
+ it('domain 없으면 → reject (domain required)', async () => {
20
+ await expect(assertDomainSubscribeAllowed(undefined, userInDomain, never)).rejects.toThrow('domain required')
21
+ await expect(assertDomainSubscribeAllowed(null, userInDomain, never)).rejects.toThrow('domain required')
22
+ })
23
+
24
+ it('user 가 해당 subdomain 소속 → 통과 (superUser 검사 불필요)', async () => {
25
+ await expect(assertDomainSubscribeAllowed(domain, userInDomain, never)).resolves.toBeUndefined()
26
+ })
27
+
28
+ it('user 가 미소속 + superUser 아님 → reject (격리 — 핵심)', async () => {
29
+ await expect(assertDomainSubscribeAllowed(domain, userOutDomain, never)).rejects.toThrow(
30
+ 'domain(acme) is not working for user(b@x.io).'
31
+ )
32
+ })
33
+
34
+ it('user 가 미소속이어도 superUser(async resolve true) 면 → 통과 (await 회귀 방지)', async () => {
35
+ await expect(assertDomainSubscribeAllowed(domain, userOutDomain, always)).resolves.toBeUndefined()
36
+ })
37
+
38
+ it('user.domains 없음 + superUser 아님 → reject', async () => {
39
+ await expect(assertDomainSubscribeAllowed(domain, { email: 'c@x.io' }, never)).rejects.toThrow(
40
+ 'domain(acme) is not working for user(c@x.io).'
41
+ )
42
+ })
43
+
44
+ it('user.domains 없음 + superUser → 통과', async () => {
45
+ await expect(assertDomainSubscribeAllowed(domain, { email: 'c@x.io' }, always)).resolves.toBeUndefined()
46
+ })
47
+
48
+ it('isSuperUser 는 domain+user 를 받아 호출되고 await 된다', async () => {
49
+ const calls: any[] = []
50
+ const spy = async (d: any, u: any) => {
51
+ calls.push([d, u])
52
+ return true
53
+ }
54
+ await assertDomainSubscribeAllowed(domain, userOutDomain, spy)
55
+ expect(calls).toEqual([[domain, userOutDomain]])
56
+ })
57
+ })
@@ -25,18 +25,14 @@ const PACKAGES = path.resolve(__dirname, '..', '..')
25
25
  const SHELL_THEMES = path.resolve(__dirname, '..', 'client', 'themes')
26
26
 
27
27
  /**
28
- * Files that carry a shell theme file's name today.
28
+ * Files that carry a shell theme file's name today - none, since 2026-09-24.
29
29
  *
30
- * - The generator templates are copied into a new application, so a new product is born replacing
31
- * the shell theme - operato-warehouse was born that way. They are fixed once the four operato
32
- * products have moved.
30
+ * figure-ui moved its palette out of themes/ (e638595af8). The generator templates, which gave every
31
+ * new application a light.css and dark.css - operato-warehouse was born replacing the shell theme
32
+ * that way - now give it `{{name}}-accent.css`, after the four operato products had moved. The list
33
+ * stays empty so a new one is caught.
33
34
  */
34
- const KNOWN: string[] = [
35
- 'generator/templates/app/client/themes/dark.css',
36
- 'generator/templates/app/client/themes/light.css',
37
- 'generator/templates/brand/client/themes/dark.css',
38
- 'generator/templates/brand/client/themes/light.css'
39
- ]
35
+ const KNOWN: string[] = []
40
36
 
41
37
  /** Every `client/themes/*.css` under packages, as a path relative to packages. */
42
38
  function themeFiles(): string[] {
@@ -147,6 +147,7 @@
147
147
  "text.there_is_nothing_to_process": "there is nothing to process",
148
148
  "text.there_is_nothing_to_save": "there is nothing to save",
149
149
  "text.you.are.now.in": "you are now in {state}",
150
+ "title.approval-line editor": "approval line editor",
150
151
  "title.activity list": "activity list",
151
152
  "title.activity model item list": "activity model item list",
152
153
  "title.approval-line item list": "approval line item list",
@@ -156,5 +157,6 @@
156
157
  "title.import event": "import events",
157
158
  "title.lookup role": "lookup role",
158
159
  "title.select_item": "select item",
159
- "term.domain": "site"
160
+ "term.domain": "site",
161
+ "field.roles": "roles"
160
162
  }
@@ -147,6 +147,7 @@
147
147
  "text.there_is_nothing_to_process": "処理するデータがありません.",
148
148
  "text.there_is_nothing_to_save": "保存するデータがありません.",
149
149
  "text.you.are.now.in": "{state}になりました.",
150
+ "title.approval-line editor": "決裁ライン編集",
150
151
  "title.activity list": "業務定義リスト",
151
152
  "title.activity model item list": "業務モデルリスト",
152
153
  "title.approval-line item list": "決裁ライン詳細",
@@ -156,5 +157,6 @@
156
157
  "title.import event": "イベントの取り込み",
157
158
  "title.lookup role": "ロール検索",
158
159
  "title.select_item": "アイテム選択",
159
- "term.domain": "事業場"
160
+ "term.domain": "事業場",
161
+ "field.roles": "ロール"
160
162
  }
@@ -147,6 +147,7 @@
147
147
  "text.there_is_nothing_to_process": "처리할 데이터가 없습니다.",
148
148
  "text.there_is_nothing_to_save": "저장할 데이터가 없습니다.",
149
149
  "text.you.are.now.in": "{state} 되었습니다.",
150
+ "title.approval-line editor": "결재선 편집",
150
151
  "title.activity list": "업무정의 리스트",
151
152
  "title.activity model item list": "업무 모델 항목",
152
153
  "title.approval-line item list": "결재선 상세",
@@ -156,5 +157,6 @@
156
157
  "title.import event": "일정 가져오기",
157
158
  "title.lookup role": "역할 검색",
158
159
  "title.select_item": "아이템 선택",
159
- "term.domain": "사업장"
160
+ "term.domain": "사업장",
161
+ "field.roles": "역할"
160
162
  }
@@ -147,6 +147,7 @@
147
147
  "text.there_is_nothing_to_process": "there is nothing to process",
148
148
  "text.there_is_nothing_to_save": "there is nothing to save",
149
149
  "text.you.are.now.in": "anda berada di {state}",
150
+ "title.approval-line editor": "editor garis kelulusan",
150
151
  "title.activity list": "senarai aktiviti",
151
152
  "title.activity model item list": "senarai item model aktiviti",
152
153
  "title.approval-line item list": "senarai item garis kelulusan",
@@ -156,5 +157,6 @@
156
157
  "title.import event": "import acara",
157
158
  "title.lookup role": "cari peranan",
158
159
  "title.select_item": "pilih item",
159
- "term.domain": "tapak"
160
+ "term.domain": "tapak",
161
+ "field.roles": "peranan"
160
162
  }
@@ -147,6 +147,7 @@
147
147
  "text.there_is_nothing_to_process": "没有要处理的数据。",
148
148
  "text.there_is_nothing_to_save": "没有要保存的数据。",
149
149
  "text.you.are.now.in": "您现在是 {state}。",
150
+ "title.approval-line editor": "审批线编辑器",
150
151
  "title.activity list": "业务列表",
151
152
  "title.activity model item list": "业务模型项目列表",
152
153
  "title.approval-line item list": "审批线项目列表",
@@ -155,5 +156,6 @@
155
156
  "title.event list": "事件列表",
156
157
  "title.import event": "导入事件",
157
158
  "title.lookup role": "查找角色",
158
- "title.select_item": "选择项目"
159
+ "title.select_item": "选择项目",
160
+ "field.roles": "角色"
159
161
  }