@carthooks/arcubase-cli 0.1.10 → 0.1.13

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 (105) hide show
  1. package/bundle/arcubase-admin.mjs +1365 -375
  2. package/bundle/arcubase.mjs +1365 -375
  3. package/dist/generated/command_registry.generated.d.ts +36 -35
  4. package/dist/generated/command_registry.generated.d.ts.map +1 -1
  5. package/dist/generated/command_registry.generated.js +37 -36
  6. package/dist/generated/type_index.generated.d.ts +23 -23
  7. package/dist/generated/type_index.generated.js +23 -23
  8. package/dist/runtime/entity_save_schema.d.ts.map +1 -1
  9. package/dist/runtime/entity_save_schema.js +30 -0
  10. package/dist/runtime/env.d.ts +2 -1
  11. package/dist/runtime/env.d.ts.map +1 -1
  12. package/dist/runtime/env.js +10 -1
  13. package/dist/runtime/errors.d.ts +3 -0
  14. package/dist/runtime/errors.d.ts.map +1 -1
  15. package/dist/runtime/execute.d.ts +4 -2
  16. package/dist/runtime/execute.d.ts.map +1 -1
  17. package/dist/runtime/execute.js +1046 -134
  18. package/dist/runtime/paths.d.ts +1 -1
  19. package/dist/runtime/paths.d.ts.map +1 -1
  20. package/dist/runtime/paths.js +9 -2
  21. package/dist/runtime/upload.d.ts +1 -11
  22. package/dist/runtime/upload.d.ts.map +1 -1
  23. package/dist/runtime/upload.js +19 -133
  24. package/dist/runtime/zod_registry.d.ts +5 -3
  25. package/dist/runtime/zod_registry.d.ts.map +1 -1
  26. package/dist/runtime/zod_registry.js +108 -48
  27. package/dist/tests/bootstrap.test.js +12 -0
  28. package/dist/tests/command_registry.test.js +8 -0
  29. package/dist/tests/docs_readability.test.d.ts +2 -0
  30. package/dist/tests/docs_readability.test.d.ts.map +1 -0
  31. package/dist/tests/docs_readability.test.js +87 -0
  32. package/dist/tests/entity_save_schema.test.js +22 -0
  33. package/dist/tests/execute_validation.test.js +443 -44
  34. package/dist/tests/help.test.js +47 -8
  35. package/dist/tests/upload.test.js +30 -77
  36. package/package.json +1 -1
  37. package/sdk-dist/docs/runtime-reference/README.md +43 -29
  38. package/sdk-dist/docs/runtime-reference/access-rule.md +128 -0
  39. package/sdk-dist/docs/runtime-reference/condition.md +182 -0
  40. package/sdk-dist/docs/runtime-reference/entity-schema/README.md +6 -5
  41. package/sdk-dist/docs/runtime-reference/entity-schema/checkbox.md +1 -1
  42. package/sdk-dist/docs/runtime-reference/entity-schema/file.md +2 -2
  43. package/sdk-dist/docs/runtime-reference/entity-schema/image.md +2 -2
  44. package/sdk-dist/docs/runtime-reference/entity-schema/linkto.md +3 -3
  45. package/sdk-dist/docs/runtime-reference/entity-schema/number.md +1 -1
  46. package/sdk-dist/docs/runtime-reference/entity-schema/query.md +3 -3
  47. package/sdk-dist/docs/runtime-reference/entity-schema/relation.md +3 -3
  48. package/sdk-dist/docs/runtime-reference/entity-schema/relationfield.md +3 -3
  49. package/sdk-dist/docs/runtime-reference/entity-schema/status.md +1 -1
  50. package/sdk-dist/docs/runtime-reference/entity-schema/text.md +1 -1
  51. package/sdk-dist/docs/runtime-reference/entity-schema.md +28 -21
  52. package/sdk-dist/docs/runtime-reference/examples/README.md +1 -1
  53. package/sdk-dist/docs/runtime-reference/examples/crm-01/README.md +30 -11
  54. package/sdk-dist/docs/runtime-reference/examples/crm-01/contact.create-entity.json +1 -0
  55. package/sdk-dist/docs/runtime-reference/examples/crm-01/contact.schema.json +1 -0
  56. package/sdk-dist/docs/runtime-reference/examples/crm-01/create-app.json +1 -0
  57. package/sdk-dist/docs/runtime-reference/examples/crm-01/customer.create-entity.json +1 -0
  58. package/sdk-dist/docs/runtime-reference/examples/crm-01/customer.schema.json +1 -0
  59. package/sdk-dist/docs/runtime-reference/examples/crm-01/follow-up.create-entity.json +1 -0
  60. package/sdk-dist/docs/runtime-reference/examples/crm-01/follow-up.schema.json +1 -0
  61. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.bulk-update.json +1 -0
  62. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.create-entity.json +1 -0
  63. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.query.json +1 -0
  64. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.row.insert.json +1 -0
  65. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.schema.json +1 -0
  66. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.selection.condition.json +1 -0
  67. package/sdk-dist/docs/runtime-reference/examples/oms-01/README.md +26 -26
  68. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.row.insert.json +1 -1
  69. package/sdk-dist/docs/runtime-reference/examples/wms-01/README.md +26 -10
  70. package/sdk-dist/docs/runtime-reference/examples/wms-01/create-app.json +1 -0
  71. package/sdk-dist/docs/runtime-reference/examples/wms-01/goods-receipt.create-entity.json +1 -0
  72. package/sdk-dist/docs/runtime-reference/examples/wms-01/goods-receipt.schema.json +1 -0
  73. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.bulk-update.json +1 -0
  74. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.create-entity.json +1 -0
  75. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.query.json +1 -0
  76. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.schema.json +1 -0
  77. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.selection.condition.json +1 -0
  78. package/sdk-dist/docs/runtime-reference/examples/wms-01/item-master.create-entity.json +1 -0
  79. package/sdk-dist/docs/runtime-reference/examples/wms-01/item-master.schema.json +1 -0
  80. package/sdk-dist/docs/runtime-reference/examples/wms-01/stock-issue.create-entity.json +1 -0
  81. package/sdk-dist/docs/runtime-reference/examples/wms-01/stock-issue.schema.json +1 -0
  82. package/sdk-dist/docs/runtime-reference/row-crud.md +48 -8
  83. package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +31 -95
  84. package/sdk-dist/docs/runtime-reference/selection.md +128 -0
  85. package/sdk-dist/docs/runtime-reference/table-lifecycle.md +113 -30
  86. package/sdk-dist/docs/runtime-reference/uploads.md +30 -59
  87. package/sdk-dist/docs/runtime-reference/workflow/README.md +129 -13
  88. package/sdk-dist/generated/command_registry.generated.ts +37 -36
  89. package/sdk-dist/generated/type_index.generated.ts +23 -23
  90. package/src/generated/command_registry.generated.ts +37 -36
  91. package/src/generated/type_index.generated.ts +23 -23
  92. package/src/runtime/entity_save_schema.ts +30 -0
  93. package/src/runtime/env.ts +14 -2
  94. package/src/runtime/errors.ts +3 -0
  95. package/src/runtime/execute.ts +1272 -134
  96. package/src/runtime/paths.ts +11 -2
  97. package/src/runtime/upload.ts +19 -178
  98. package/src/runtime/zod_registry.ts +113 -48
  99. package/src/tests/bootstrap.test.ts +16 -0
  100. package/src/tests/command_registry.test.ts +9 -0
  101. package/src/tests/docs_readability.test.ts +96 -0
  102. package/src/tests/entity_save_schema.test.ts +22 -0
  103. package/src/tests/execute_validation.test.ts +577 -47
  104. package/src/tests/help.test.ts +51 -8
  105. package/src/tests/upload.test.ts +34 -91
@@ -9,6 +9,7 @@ import { CLIError } from '../runtime/errors.js'
9
9
  const env = {
10
10
  ARCUBASE_BASE_URL: 'https://arcubase.example.com',
11
11
  ARCUBASE_ACCESS_TOKEN: 'tok',
12
+ ARCUBASE_SDK_ROOT: '/runtime/arcubase-sdk',
12
13
  ARCUBASE_TRACE_ID: '',
13
14
  ARCUBASE_SUBJECT_TYPE: '',
14
15
  ARCUBASE_SUBJECT_ID: '',
@@ -22,6 +23,28 @@ function tempJSONFile(payload: unknown): string {
22
23
  return filePath
23
24
  }
24
25
 
26
+ function withRowPolicyResolver(
27
+ tableId = 'table_1',
28
+ actions = ['view', 'add', 'edit', 'delete', 'bulk_update', 'archive'],
29
+ policyOverridesOrFinal: Record<string, unknown> | typeof fetch = {},
30
+ final?: typeof fetch,
31
+ ): typeof fetch {
32
+ const policyOverrides = typeof policyOverridesOrFinal === 'function' ? {} : policyOverridesOrFinal
33
+ const finalFetch = typeof policyOverridesOrFinal === 'function'
34
+ ? policyOverridesOrFinal
35
+ : final ?? (async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }))
36
+ return async (url, init) => {
37
+ const text = String(url)
38
+ if (/\/api\/apps\/[^/?]+$/.test(text) && init?.method === 'GET') {
39
+ return new Response(JSON.stringify({ data: { ingress: { groups: [{ items: [{ id: 'policy_hash_1' }] }] } } }), { status: 200 })
40
+ }
41
+ if (/\/api\/ingress\/[^/]+\/policy_hash_1$/.test(text) && init?.method === 'GET') {
42
+ return new Response(JSON.stringify({ data: { id: tableId, policy: { actions, ...policyOverrides } } }), { status: 200 })
43
+ }
44
+ return finalFetch(url, init)
45
+ }
46
+ }
47
+
25
48
  function assertAssignUsersGuidance(error: unknown, messagePattern?: RegExp): boolean {
26
49
  assert.ok(error instanceof CLIError)
27
50
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
@@ -32,7 +55,7 @@ function assertAssignUsersGuidance(error: unknown, messagePattern?: RegExp): boo
32
55
  assert.deepEqual(error.details?.shapeHint, {
33
56
  update: ['user_scope'],
34
57
  options: {
35
- user_scope: [{ type: 'member', id: 123 }],
58
+ user_scope: [{ type: 'user', id: 2188889901 }],
36
59
  },
37
60
  })
38
61
  assert.deepEqual(error.details?.commonMistakes, [
@@ -41,9 +64,9 @@ function assertAssignUsersGuidance(error: unknown, messagePattern?: RegExp): boo
41
64
  'do not use hash tenant user id as id',
42
65
  'id must be numeric TenantUser.ID',
43
66
  ])
44
- assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'AppIngressUpdateReqVO' && item.file === '/opt/arcubase-sdk/types/ingress.ts'))
45
- assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityIngressOptions' && item.file === '/opt/arcubase-sdk/types/ingress.ts'))
46
- assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'PermitUserScope' && item.file === '/opt/arcubase-sdk/types/common.ts'))
67
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'AppIngressUpdateReqVO' && item.file === '/runtime/arcubase-sdk/types/ingress.ts'))
68
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityIngressOptions' && item.file === '/runtime/arcubase-sdk/types/ingress.ts'))
69
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'PermitUserScope' && item.file === '/runtime/arcubase-sdk/types/common.ts'))
47
70
  return true
48
71
  }
49
72
 
@@ -109,6 +132,15 @@ test('table update-schema rejects invalid body file', async () => {
109
132
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
110
133
  assert.equal(error.details?.requestType, 'EntitySaveReqVO')
111
134
  assert.ok((error.details?.issues ?? []).some((item) => item.path.startsWith('body.layout')))
135
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntitySaveReqVO' && item.file === '/runtime/arcubase-sdk/types/entity.ts'))
136
+ assert.ok(error.details?.docHints?.some((item) => item.file === '/runtime/arcubase-sdk/docs/runtime-reference/entity-schema.md'))
137
+ assert.equal((error.details?.shapeHint as any)?.field_id_seq, 1004)
138
+ assert.equal((error.details?.shapeHint as any)?.fields?.[0]?.type, 'text')
139
+ assert.equal((error.details?.shapeHint as any)?.fields?.[1]?.type, 'textarea')
140
+ assert.equal((error.details?.shapeHint as any)?.fields?.[2]?.type, 'select')
141
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('fields must be an array')))
142
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('get table shell first')))
143
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('not "string"')))
112
144
  return true
113
145
  })
114
146
  })
@@ -125,40 +157,68 @@ test('app create rejects name longer than 20 chars', async () => {
125
157
  })
126
158
  })
127
159
 
128
- test('table create executes with valid body file', async () => {
129
- const bodyFile = tempJSONFile({ name: '订单', parent: 0 })
160
+ test('table create rejects empty schema body before request', async () => {
161
+ await assert.rejects(async () => {
162
+ await executeCLI('admin', ['table', 'create', '--app-id', '2188889844', '--body-json', '{"name":"订单","parent":0}'], env as any, async () => new Response('{}'))
163
+ }, (error: unknown) => {
164
+ assert.ok(error instanceof CLIError)
165
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
166
+ assert.match(error.message, /fields/)
167
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase-admin table create --app-id <app_id> --body-json')))
168
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('"field_id_seq":1003')))
169
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('"投票描述"')))
170
+ return true
171
+ })
172
+ })
173
+
174
+ test('table create executes create and schema save with valid body file', async () => {
175
+ const bodyFile = tempJSONFile({ ...validEntitySavePayload({ id: undefined, app_id: undefined }), name: '订单', parent: 0 })
176
+ const calls: Array<{ url: string; method?: string; body: any }> = []
130
177
  const out = await executeCLI(
131
178
  'admin',
132
- ['table', 'create', '--app-id', 'app_1', '--body-file', bodyFile],
179
+ ['table', 'create', '--app-id', '2188889844', '--body-file', bodyFile],
133
180
  env as any,
134
- async (url, init) =>
135
- new Response(JSON.stringify({
136
- url,
137
- method: init?.method,
138
- hasTenantHeader: Object.prototype.hasOwnProperty.call((init?.headers as Record<string, string>) ?? {}, 'X-Tenant-Id'),
139
- hasHostHeader: Object.prototype.hasOwnProperty.call((init?.headers as Record<string, string>) ?? {}, 'X-Host'),
140
- }), { status: 200 }),
181
+ async (url, init) => {
182
+ calls.push({ url: String(url), method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : null })
183
+ if (init?.method === 'POST') {
184
+ return new Response(JSON.stringify({ data: { id: 2188889845 } }), { status: 200 })
185
+ }
186
+ return new Response(JSON.stringify({ data: { id: 2188889845, fields: calls[1]?.body?.fields ?? [] } }), { status: 200 })
187
+ },
141
188
  )
142
189
  assert.equal(out.kind, 'result')
143
- assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entities')
144
- assert.equal(out.data.hasTenantHeader, false)
145
- assert.equal(out.data.hasHostHeader, false)
190
+ assert.equal(out.data.id, 2188889845)
191
+ assert.equal(calls.length, 2)
192
+ assert.equal(calls[0].url, 'https://arcubase.example.com/api/apps/2188889844/entities')
193
+ assert.equal(calls[0].method, 'POST')
194
+ assert.deepEqual(calls[0].body, { name: '订单', parent: 0 })
195
+ assert.equal(calls[1].url, 'https://arcubase.example.com/api/apps/2188889844/entity/2188889845')
196
+ assert.equal(calls[1].method, 'PUT')
197
+ assert.equal(calls[1].body.id, 2188889845)
198
+ assert.equal(calls[1].body.app_id, 2188889844)
199
+ assert.equal(calls[1].body.fields.length, 1)
146
200
  })
147
201
 
148
202
  test('table create executes with valid body json', async () => {
203
+ const calls: Array<{ url: string; method?: string; body: any }> = []
204
+ const payload = JSON.stringify({ ...validEntitySavePayload({ id: undefined, app_id: undefined }), name: '订单', parent: 0 })
149
205
  const out = await executeCLI(
150
206
  'admin',
151
- ['table', 'create', '--app-id', 'app_1', '--body-json', '{"name":"订单","parent":0}'],
207
+ ['table', 'create', '--app-id', '2188889844', '--body-json', payload],
152
208
  env as any,
153
- async (url, init) => new Response(JSON.stringify({
154
- url,
155
- method: init?.method,
156
- body: init?.body ? JSON.parse(String(init.body)) : null,
157
- }), { status: 200 }),
209
+ async (url, init) => {
210
+ calls.push({ url: String(url), method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : null })
211
+ if (init?.method === 'POST') {
212
+ return new Response(JSON.stringify({ data: { id: 2188889845 } }), { status: 200 })
213
+ }
214
+ return new Response(JSON.stringify({ data: { id: 2188889845, fields: calls[1]?.body?.fields ?? [] } }), { status: 200 })
215
+ },
158
216
  )
159
217
  assert.equal(out.kind, 'result')
160
- assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entities')
161
- assert.deepEqual(out.data.body, { name: '订单', parent: 0 })
218
+ assert.equal(out.data.id, 2188889845)
219
+ assert.equal(calls.length, 2)
220
+ assert.deepEqual(calls[0].body, { name: '订单', parent: 0 })
221
+ assert.equal(calls[1].body.id, 2188889845)
162
222
  })
163
223
 
164
224
  test('body json conflicts with body file', async () => {
@@ -182,6 +242,22 @@ test('body json is rejected for commands without request type', async () => {
182
242
  })
183
243
  })
184
244
 
245
+ test('entry rejects retired include-app-tables flag before fetch', async () => {
246
+ let called = false
247
+ await assert.rejects(async () => {
248
+ await executeCLI('user', ['entry', '--include-app-tables'], env as any, async () => {
249
+ called = true
250
+ return new Response('{}', { status: 200 })
251
+ })
252
+ }, (error: unknown) => {
253
+ assert.ok(error instanceof CLIError)
254
+ assert.equal(error.code, 'UNKNOWN_FLAG')
255
+ assert.match(error.message, /--include-app-tables/)
256
+ return true
257
+ })
258
+ assert.equal(called, false)
259
+ })
260
+
185
261
  test('body json parse errors fail fast', async () => {
186
262
  await assert.rejects(async () => {
187
263
  await executeCLI('admin', ['table', 'create', '--app-id', 'app_1', '--body-json', '{"name":'], env as any, async () => new Response('{}', { status: 200 }))
@@ -192,6 +268,51 @@ test('body json parse errors fail fast', async () => {
192
268
  })
193
269
  })
194
270
 
271
+ test('body json parse errors include command docs and type hints', async () => {
272
+ await assert.rejects(async () => {
273
+ await executeCLI('admin', ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["options"]}]}'], env as any, async () => new Response('{}', { status: 200 }))
274
+ }, (error: unknown) => {
275
+ assert.ok(error instanceof CLIError)
276
+ assert.equal(error.code, 'INVALID_BODY_JSON')
277
+ assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
278
+ assert.ok((error.details?.docHints ?? []).some((item) => item.file === '/runtime/arcubase-sdk/docs/runtime-reference/access-rule.md'))
279
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('access-rule update policy changes')))
280
+ return true
281
+ })
282
+ })
283
+
284
+ test('access-rule invalid json returns copyable create retry examples', async () => {
285
+ await assert.rejects(async () => {
286
+ await executeCLI('admin', ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"Admin full access","enabled":true,"type":"form","options":{"user_scope":[{"type":"user","id":2188889977}],"policy":{"key":"custom","actions":["view","add","edit","delete"],"all_fields_read":true,"all_fields_write":true},"list_options":{}}'], env as any, async () => new Response('{}', { status: 200 }))
287
+ }, (error: unknown) => {
288
+ assert.ok(error instanceof CLIError)
289
+ assert.equal(error.code, 'INVALID_BODY_JSON')
290
+ assert.equal(error.details?.requestType, 'AppIngressCreateReqVO')
291
+ assert.match(error.message, /retryToolCalls/)
292
+ const toolCall = (error.details?.retryToolCalls ?? [])[0] as any
293
+ assert.ok(toolCall)
294
+ assert.equal(toolCall.command, 'access-rule create')
295
+ assert.deepEqual(toolCall.args.slice(0, 5), ['--app-id', '<app_id>', '--table-id', '<table_id>', '--body-json'])
296
+ const body = JSON.parse(toolCall.args[5])
297
+ assert.deepEqual(body, {
298
+ name: 'Admin full access',
299
+ enabled: true,
300
+ type: 'form',
301
+ options: {
302
+ user_scope: [{ type: 'user', id: 2188889977 }],
303
+ policy: {
304
+ key: 'custom',
305
+ actions: ['view', 'add', 'edit', 'delete'],
306
+ all_fields_read: true,
307
+ all_fields_write: true,
308
+ },
309
+ list_options: {},
310
+ },
311
+ })
312
+ return true
313
+ })
314
+ })
315
+
195
316
  test('access-rule list executes against external root path', async () => {
196
317
  const out = await executeCLI(
197
318
  'admin',
@@ -256,6 +377,36 @@ test('table update-schema normalizes shell null placeholders before validation',
256
377
  assert.deepEqual(requestBody.options.misc, {})
257
378
  })
258
379
 
380
+ test('table update-schema rejects per-user permission patches', async () => {
381
+ const body = JSON.stringify({
382
+ id: 2188893443,
383
+ app_id: 2188893436,
384
+ name: '投票项目',
385
+ schema_version: 1,
386
+ field_id_seq: 1003,
387
+ layout: [[1001], [1002]],
388
+ fields: [
389
+ { id: 1001, label: 'Title', type: 'text', required: true, unique: false, editable: true, visible: true, show_label: true, scannable: false, default_value_mode: 0, description: '', value: null, number_decimal: 0, depends: [], key: 'title', code_index: false, options: { type: 'text', placeholder: '', lengthLimit: false, lengthMin: 0, lengthMax: 255 }, transfers: null, children: null },
390
+ { id: 1002, label: 'Description', type: 'textarea', required: false, unique: false, editable: true, visible: true, show_label: true, scannable: false, default_value_mode: 0, description: '', value: null, number_decimal: 0, depends: [], key: 'description', code_index: false, options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 }, transfers: null, children: null, permission: { user: { read: false } } },
391
+ ],
392
+ options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
393
+ workflow_enabled: false,
394
+ })
395
+ await assert.rejects(async () => {
396
+ await executeCLI(
397
+ 'admin',
398
+ ['table', 'update-schema', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', body],
399
+ env as any,
400
+ async () => new Response('{}', { status: 200 }),
401
+ )
402
+ }, (error: unknown) => {
403
+ assert.ok(error instanceof CLIError)
404
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
405
+ assert.match(error.message, /access-rule/)
406
+ return true
407
+ })
408
+ })
409
+
259
410
  test('row delete rejects selection type outside ids', async () => {
260
411
  const bodyFile = tempJSONFile({ selection: { type: 'selection', ids: [4000000003], length: 1 } })
261
412
  await assert.rejects(async () => {
@@ -277,7 +428,7 @@ test('row bulk-update accepts ids selection with number_add action', async () =>
277
428
  'user',
278
429
  ['row', 'bulk-update', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
279
430
  env as any,
280
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
431
+ withRowPolicyResolver(),
281
432
  )
282
433
  assert.equal(out.kind, 'result')
283
434
  assert.deepEqual(out.data.selection, { type: 'ids', ids: [4000000003], length: 1 })
@@ -297,7 +448,7 @@ test('row selection-action accepts condition selection with selectAll', async ()
297
448
  'user',
298
449
  ['row', 'selection-action', '--app-id', 'app_1', '--table-id', 'table_1', '--action', 'archive', '--body-file', bodyFile],
299
450
  env as any,
300
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
451
+ withRowPolicyResolver(),
301
452
  )
302
453
  assert.equal(out.kind, 'result')
303
454
  assert.equal(out.data.selection.type, 'condition')
@@ -316,7 +467,176 @@ test('row selection-action rejects condition selection for query action', async
316
467
  })
317
468
  })
318
469
 
319
- test('row create rejects local file path for file field and points to arcubase upload', async () => {
470
+ test('row create resolves internal policy_id from accessible ingress', async () => {
471
+ const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' } })
472
+ const calls: Array<{ url: string; method?: string; body: any }> = []
473
+ const out = await executeCLI(
474
+ 'user',
475
+ ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
476
+ env as any,
477
+ withRowPolicyResolver('2188900694', ['view', 'add'], async (url, init) => {
478
+ calls.push({
479
+ url: String(url),
480
+ method: init?.method,
481
+ body: init?.body ? JSON.parse(String(init.body)) : null,
482
+ })
483
+ return new Response(String(init?.body ?? '{}'), { status: 200 })
484
+ }),
485
+ )
486
+ assert.equal(out.kind, 'result')
487
+ assert.deepEqual(calls, [
488
+ { url: 'https://arcubase.example.com/api/entity/2188900691/2188900694', method: 'GET', body: null },
489
+ {
490
+ url: 'https://arcubase.example.com/api/entity/2188900691/2188900694/insert',
491
+ method: 'POST',
492
+ body: { fields: { 1001: '今天吃什么' }, policy_id: 'policy_hash_1' },
493
+ },
494
+ ])
495
+ })
496
+
497
+ test('row get resolves internal policy_id as query parameter', async () => {
498
+ const calls: string[] = []
499
+ const out = await executeCLI(
500
+ 'user',
501
+ ['row', 'get', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '4000000046'],
502
+ env as any,
503
+ withRowPolicyResolver('2188900694', ['view'], async (url) => {
504
+ calls.push(String(url))
505
+ return new Response(JSON.stringify({ data: { id: 4000000046 } }), { status: 200 })
506
+ }),
507
+ )
508
+ assert.equal(out.kind, 'result')
509
+ assert.equal(calls[0], 'https://arcubase.example.com/api/entity/2188900691/2188900694/row/4000000046?policy_id=policy_hash_1')
510
+ })
511
+
512
+ test('row get returns machine-readable row value evidence', async () => {
513
+ const out = await executeCLI(
514
+ 'user',
515
+ ['row', 'get', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '4000000046'],
516
+ env as any,
517
+ withRowPolicyResolver('2188900694', ['view'], async () => new Response(JSON.stringify({
518
+ data: {
519
+ record: {
520
+ id: 4000000046,
521
+ fields: { 1001: '复测投票' },
522
+ },
523
+ fieldPermits: {
524
+ all_fields_read: false,
525
+ fields: [
526
+ { key: ':1001', read: true, write: true, report: false },
527
+ { key: ':1002', read: false, write: false, report: false },
528
+ { key: ':1003', read: true, write: true, report: false },
529
+ ],
530
+ },
531
+ },
532
+ }), { status: 200 })),
533
+ )
534
+ assert.deepEqual(out.rowValueEvidence, {
535
+ source: 'row get record.fields',
536
+ returnedFieldIds: ['1001'],
537
+ notReturnedFieldIds: ['1002', '1003'],
538
+ deniedReadFieldIds: ['1002'],
539
+ absentFieldMeaning: 'not returned to current user; no row-value evidence',
540
+ rules: [
541
+ 'current row values come only from row query fields or row get record.fields',
542
+ 'table schema fields[].value and default_value_mode are schema defaults, not returned row values',
543
+ 'a field id absent from returned row fields has no row-value evidence',
544
+ 'read visibility status is fieldPermits read:false; FIELD_PERMISSION_REQUIRED is only a write preflight error',
545
+ ],
546
+ })
547
+ })
548
+
549
+ test('row command rejects user-provided policy_id', async () => {
550
+ const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' }, policy_id: 'manual_policy' })
551
+ await assert.rejects(async () => {
552
+ await executeCLI(
553
+ 'user',
554
+ ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
555
+ env as any,
556
+ withRowPolicyResolver('2188900694', ['add']),
557
+ )
558
+ }, (error: unknown) => {
559
+ assert.ok(error instanceof CLIError)
560
+ assert.equal(error.code, 'INTERNAL_POLICY_ID_NOT_ALLOWED')
561
+ assert.match(error.message, /internal Arcubase ingress parameter/)
562
+ return true
563
+ })
564
+ })
565
+
566
+ test('row command fastfails when no accessible ingress matches action', async () => {
567
+ const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' } })
568
+ await assert.rejects(async () => {
569
+ await executeCLI(
570
+ 'user',
571
+ ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
572
+ env as any,
573
+ withRowPolicyResolver('2188900694', ['view']),
574
+ )
575
+ }, (error: unknown) => {
576
+ assert.ok(error instanceof CLIError)
577
+ assert.equal(error.code, 'POLICY_ID_RESOLUTION_FAILED')
578
+ assert.match(error.message, /action add/)
579
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('access-rule')))
580
+ return true
581
+ })
582
+ })
583
+
584
+ test('row create fastfails before request when payload writes non-writable field', async () => {
585
+ const bodyFile = tempJSONFile({ fields: { 1001: '标题', 1002: '隐藏描述' } })
586
+ const calls: Array<{ url: string; method?: string }> = []
587
+ await assert.rejects(async () => {
588
+ await executeCLI(
589
+ 'user',
590
+ ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
591
+ env as any,
592
+ withRowPolicyResolver(
593
+ '2188900694',
594
+ ['view', 'add'],
595
+ { all_fields_write: true, fields: [{ key: ':1002', read: false, write: false, report: false }] },
596
+ async (url, init) => {
597
+ calls.push({ url: String(url), method: init?.method })
598
+ return new Response(String(init?.body ?? '{}'), { status: 200 })
599
+ },
600
+ ),
601
+ )
602
+ }, (error: unknown) => {
603
+ assert.ok(error instanceof CLIError)
604
+ assert.equal(error.code, 'FIELD_PERMISSION_REQUIRED')
605
+ assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields.1002'))
606
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('remove non-writable field ids')))
607
+ return true
608
+ })
609
+ assert.equal(calls.some((call) => call.method !== 'GET'), false)
610
+ })
611
+
612
+ test('row update fastfails before request when payload writes non-writable field', async () => {
613
+ const bodyFile = tempJSONFile({ data: { 1002: '隐藏描述' }, changed_fields: [1002] })
614
+ const calls: Array<{ url: string; method?: string }> = []
615
+ await assert.rejects(async () => {
616
+ await executeCLI(
617
+ 'user',
618
+ ['row', 'update', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '2188901452', '--body-file', bodyFile],
619
+ env as any,
620
+ withRowPolicyResolver(
621
+ '2188900694',
622
+ ['view', 'edit'],
623
+ { all_fields_write: false, fields: [{ key: ':1001', read: true, write: true, report: false }, { key: ':1002', read: false, write: false, report: false }] },
624
+ async (url, init) => {
625
+ calls.push({ url: String(url), method: init?.method })
626
+ return new Response(String(init?.body ?? '{}'), { status: 200 })
627
+ },
628
+ ),
629
+ )
630
+ }, (error: unknown) => {
631
+ assert.ok(error instanceof CLIError)
632
+ assert.equal(error.code, 'FIELD_PERMISSION_REQUIRED')
633
+ assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.data.1002'))
634
+ return true
635
+ })
636
+ assert.equal(calls.some((call) => call.method !== 'GET'), false)
637
+ })
638
+
639
+ test('row create rejects local file path for file field and points to upload token flow', async () => {
320
640
  const bodyFile = tempJSONFile({ fields: { 1001: 'Lead A', 1007: './contract.pdf' } })
321
641
  const calls: Array<{ url: string; method?: string }> = []
322
642
  await assert.rejects(async () => {
@@ -336,12 +656,31 @@ test('row create rejects local file path for file field and points to arcubase u
336
656
  assert.ok(error instanceof CLIError)
337
657
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
338
658
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields.1007'))
339
- assert.match(error.message, /arcubase upload \.\/contract\.pdf/)
659
+ assert.match(error.message, /upload token flow/)
340
660
  return true
341
661
  })
342
662
  assert.deepEqual(calls, [{ url: 'https://arcubase.example.com/api/entity/app_1/table_1', method: 'GET' }])
343
663
  })
344
664
 
665
+ test('row create non-numeric field key gives table get retry path', async () => {
666
+ const bodyFile = tempJSONFile({ fields: { 标题: '今天吃什么' } })
667
+ await assert.rejects(async () => {
668
+ await executeCLI(
669
+ 'user',
670
+ ['row', 'create', '--app-id', '2188900691', '--table-id', '2188900694', '--body-file', bodyFile],
671
+ env as any,
672
+ async () => new Response('{}', { status: 200 }),
673
+ )
674
+ }, (error: unknown) => {
675
+ assert.ok(error instanceof CLIError)
676
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
677
+ assert.ok(error.details?.commonMistakes?.some((item) => item.includes('do not use field labels')))
678
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase table get')))
679
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('arcubase row create')))
680
+ return true
681
+ })
682
+ })
683
+
345
684
  test('row update rejects object value for image field and requires upload array', async () => {
346
685
  const bodyFile = tempJSONFile({ changed_fields: [1008], data: { 1008: { upload_id: 123456, file: 'lead-photo.jpg', file_name: 'lead-photo.jpg', meta: {} } } })
347
686
  await assert.rejects(async () => {
@@ -360,7 +699,7 @@ test('row update rejects object value for image field and requires upload array'
360
699
  assert.ok(error instanceof CLIError)
361
700
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
362
701
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.data.1008'))
363
- assert.match(error.message, /must be an array/)
702
+ assert.match(error.message, /upload value array/)
364
703
  return true
365
704
  })
366
705
  })
@@ -372,13 +711,13 @@ test('row create accepts upload array for file field after schema preflight', as
372
711
  'user',
373
712
  ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
374
713
  env as any,
375
- async (url, init) => {
714
+ withRowPolicyResolver('table_1', ['add'], async (url, init) => {
376
715
  calls.push({ url: String(url), method: init?.method })
377
716
  if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
378
717
  return new Response(JSON.stringify({ data: { id: 1, app_id: 1, fields: [{ id: 1001, type: 'text', label: 'Lead Name' }, { id: 1007, type: 'file', label: 'Contract File' }] } }), { status: 200 })
379
718
  }
380
719
  return new Response(String(init?.body ?? '{}'), { status: 200 })
381
- },
720
+ }),
382
721
  )
383
722
  assert.equal(out.kind, 'result')
384
723
  assert.deepEqual(calls, [
@@ -395,7 +734,7 @@ test('row create decorates upload bind failures with actionable guidance', async
395
734
  'user',
396
735
  ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
397
736
  env as any,
398
- async (url, init) => {
737
+ withRowPolicyResolver('table_1', ['add'], async (url, init) => {
399
738
  if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
400
739
  return new Response(JSON.stringify({ data: { fields: [{ id: 1007, type: 'file', label: 'Contract File' }] } }), { status: 200 })
401
740
  }
@@ -407,14 +746,14 @@ test('row create decorates upload bind failures with actionable guidance', async
407
746
  },
408
747
  trace_id: 'req_upload_bind',
409
748
  }), { status: 200 })
410
- },
749
+ }),
411
750
  )
412
751
  }, (error: unknown) => {
413
752
  assert.ok(error instanceof CLIError)
414
753
  assert.equal(error.code, 'UPSTREAM_BODY_ERROR')
415
754
  assert.equal(error.details?.traceId, 'req_upload_bind')
416
755
  assert.equal(error.details?.reason, 'the row payload used a file/image upload entry, but Arcubase could not bind that upload into an asset record')
417
- assert.match(error.details?.hint ?? '', /rerun arcubase upload/)
756
+ assert.match(error.details?.hint ?? '', /upload token flow/)
418
757
  return true
419
758
  })
420
759
  })
@@ -431,6 +770,30 @@ test('row query rejects camelCase viewMode and points to row crud doc', async ()
431
770
  })
432
771
  })
433
772
 
773
+ test('row query unknown limit flag gives body-json retry path', async () => {
774
+ await assert.rejects(async () => {
775
+ await executeCLI('user', ['row', 'query', '--app-id', '2188900691', '--table-id', '2188900694', '--limit', '1', '--offset', '0'], env as any, async () => new Response('{}', { status: 200 }))
776
+ }, (error: unknown) => {
777
+ assert.ok(error instanceof CLIError)
778
+ assert.equal(error.code, 'UNKNOWN_FLAG')
779
+ assert.equal(error.details?.requestType, 'EntityQueryReqVO')
780
+ assert.deepEqual(error.details?.shapeHint, { limit: 20, offset: 0, search: { text_search: 'SO-1001' } })
781
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('--body-json')))
782
+ return true
783
+ })
784
+ })
785
+
786
+ test('table dataset unknown app flag points to table get', async () => {
787
+ await assert.rejects(async () => {
788
+ await executeCLI('user', ['table', 'dataset', '--app-id', '2188900691', '--table-id', '2188900694'], env as any, async () => new Response('{}', { status: 200 }))
789
+ }, (error: unknown) => {
790
+ assert.ok(error instanceof CLIError)
791
+ assert.equal(error.code, 'UNKNOWN_FLAG')
792
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('table get --app-id')))
793
+ return true
794
+ })
795
+ })
796
+
434
797
  test('row update rejects fields key', async () => {
435
798
  const bodyFile = tempJSONFile({ fields: { 1002: 99 }, data: { '1002': 99 }, changed_fields: [1002] })
436
799
  await assert.rejects(async () => {
@@ -456,7 +819,7 @@ test('row update rejects changed_fields that are missing from data', async () =>
456
819
  })
457
820
 
458
821
  test('workflow approve maps to fixed action path', async () => {
459
- const bodyFile = tempJSONFile({ task_id: 1001, fields: {}, changed: [] })
822
+ const bodyFile = tempJSONFile({ task_id: 2188890521, fields: {}, changed: [] })
460
823
  const out = await executeCLI(
461
824
  'user',
462
825
  ['workflow', 'approve', '--app-id', 'app_1', '--table-id', 'table_1', '--row-id', 'row_1', '--body-file', bodyFile],
@@ -465,7 +828,7 @@ test('workflow approve maps to fixed action path', async () => {
465
828
  )
466
829
  assert.equal(out.kind, 'result')
467
830
  assert.equal(out.data.url, 'https://arcubase.example.com/api/entity/app_1/table_1/row/row_1/action/2')
468
- assert.equal(out.data.body.task_id, 1001)
831
+ assert.equal(out.data.body.task_id, 2188890521)
469
832
  })
470
833
 
471
834
  test('workflow query-approve-users requires request body', async () => {
@@ -478,10 +841,10 @@ test('workflow query-approve-users requires request body', async () => {
478
841
  })
479
842
  })
480
843
 
481
- test('entry passes include_app_tables query flag', async () => {
844
+ test('entry always includes app tables without exposing a query flag', async () => {
482
845
  const out = await executeCLI(
483
846
  'user',
484
- ['entry', '--include-app-tables', 'true'],
847
+ ['entry'],
485
848
  env as any,
486
849
  async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }),
487
850
  )
@@ -489,6 +852,45 @@ test('entry passes include_app_tables query flag', async () => {
489
852
  assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true')
490
853
  })
491
854
 
855
+ test('entry fixed app tables query cannot be disabled by query file', async () => {
856
+ const queryFile = tempJSONFile({ include_app_tables: false })
857
+ const out = await executeCLI(
858
+ 'user',
859
+ ['entry', '--query-file', queryFile],
860
+ env as any,
861
+ async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }),
862
+ )
863
+ assert.equal(out.kind, 'result')
864
+ assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true')
865
+ })
866
+
867
+ test('upstream http failures include endpoint status and body details', async () => {
868
+ await assert.rejects(async () => {
869
+ await executeCLI('user', ['entry'], env as any, async () => new Response('', { status: 500 }))
870
+ }, (error: unknown) => {
871
+ assert.ok(error instanceof CLIError)
872
+ assert.equal(error.code, 'UPSTREAM_REQUEST_FAILED')
873
+ assert.equal(error.details?.endpoint, '/api/entry')
874
+ assert.equal(error.details?.method, 'GET')
875
+ assert.equal(error.details?.status, 500)
876
+ return true
877
+ })
878
+ })
879
+
880
+ test('upstream network failures are separated from http failures', async () => {
881
+ await assert.rejects(async () => {
882
+ await executeCLI('user', ['entry'], env as any, async () => {
883
+ throw new Error('dns failed')
884
+ })
885
+ }, (error: unknown) => {
886
+ assert.ok(error instanceof CLIError)
887
+ assert.equal(error.code, 'UPSTREAM_NETWORK_ERROR')
888
+ assert.equal(error.details?.endpoint, '/api/entry')
889
+ assert.match(error.message, /dns failed/)
890
+ return true
891
+ })
892
+ })
893
+
492
894
  test('access-rule update rejects enabled without update whitelist', async () => {
493
895
  await assert.rejects(async () => {
494
896
  await executeCLI(
@@ -502,7 +904,7 @@ test('access-rule update rejects enabled without update whitelist', async () =>
502
904
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
503
905
  assert.match(error.message, /update.*enabled/)
504
906
  assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
505
- assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/opt/arcubase-sdk/types/ingress.ts'))
907
+ assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/runtime/arcubase-sdk/types/ingress.ts'))
506
908
  return true
507
909
  })
508
910
  })
@@ -518,6 +920,134 @@ test('access-rule update accepts enabled with update whitelist', async () => {
518
920
  assert.deepEqual(out.data, { update: ['enabled'], enabled: true })
519
921
  })
520
922
 
923
+ test('access-rule update rejects nested options patch with canonical policy example', async () => {
924
+ await assert.rejects(async () => {
925
+ await executeCLI(
926
+ 'admin',
927
+ ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["options.policy"],"options":{"policy":{"key":"custom","actions":["view","add"],"fields":[{"key":"1002","read":false}]}}}'],
928
+ env as any,
929
+ async () => new Response('{}', { status: 200 }),
930
+ )
931
+ }, (error: unknown) => {
932
+ assert.ok(error instanceof CLIError)
933
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
934
+ assert.match(error.message, /update.*options/)
935
+ assert.match(error.message, /:1002/)
936
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('options.policy')))
937
+ return true
938
+ })
939
+ })
940
+
941
+ test('access-rule update accepts canonical policy body with hidden field', async () => {
942
+ const body = '{"update":["options"],"options":{"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":true,"all_fields_write":true,"fields":[{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}'
943
+ const out = await executeCLI(
944
+ 'admin',
945
+ ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', body],
946
+ env as any,
947
+ async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
948
+ )
949
+ assert.equal(out.kind, 'result')
950
+ assert.deepEqual(out.data.update, ['options'])
951
+ assert.deepEqual(out.data.options.policy.fields, [{ key: ':1002', read: false, write: false, report: false }])
952
+ })
953
+
954
+ test('access-rule update rejects partial options user scope overwrite', async () => {
955
+ await assert.rejects(async () => {
956
+ await executeCLI(
957
+ 'admin',
958
+ ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["options"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}'],
959
+ env as any,
960
+ async () => new Response('{}', { status: 200 }),
961
+ )
962
+ }, (error: unknown) => {
963
+ assert.ok(error instanceof CLIError)
964
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
965
+ assert.match(error.message, /options.*policy/)
966
+ assert.match(error.message, /assign-users/)
967
+ return true
968
+ })
969
+ })
970
+
971
+ test('access-rule update rejects FieldVO key names in policy fields', async () => {
972
+ await assert.rejects(async () => {
973
+ await executeCLI(
974
+ 'admin',
975
+ ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["options"],"options":{"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":false,"all_fields_write":false,"fields":[{"key":"vote_description","read":false,"write":false,"report":false}]},"list_options":{}}}'],
976
+ env as any,
977
+ async () => new Response('{}', { status: 200 }),
978
+ )
979
+ }, (error: unknown) => {
980
+ assert.ok(error instanceof CLIError)
981
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
982
+ assert.match(error.message, /:1002/)
983
+ assert.match(error.message, /vote_name|FieldVO\.key/)
984
+ return true
985
+ })
986
+ })
987
+
988
+ test('access-rule create rejects guessed permission aliases', async () => {
989
+ await assert.rejects(async () => {
990
+ await executeCLI(
991
+ 'admin',
992
+ ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"rule","enabled":true,"permissions":["read","write"],"field_visibility":[{"field_id":1002,"visible":false}]}'],
993
+ env as any,
994
+ async () => new Response('{}', { status: 200 }),
995
+ )
996
+ }, (error: unknown) => {
997
+ assert.ok(error instanceof CLIError)
998
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
999
+ assert.match(error.message, /options\.policy/)
1000
+ assert.equal(error.details?.requestType, 'AppIngressCreateReqVO')
1001
+ assert.ok((error.details?.commonMistakes ?? []).some((item) => item.includes('field_visibility')))
1002
+ return true
1003
+ })
1004
+ })
1005
+
1006
+ test('access-rule create rejects read write action aliases', async () => {
1007
+ await assert.rejects(async () => {
1008
+ await executeCLI(
1009
+ 'admin',
1010
+ ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"rule","enabled":true,"type":"form","options":{"policy":{"key":"custom","actions":["read","write"]},"list_options":{}}}'],
1011
+ env as any,
1012
+ async () => new Response('{}', { status: 200 }),
1013
+ )
1014
+ }, (error: unknown) => {
1015
+ assert.ok(error instanceof CLIError)
1016
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
1017
+ assert.match(error.message, /view.*add.*edit/)
1018
+ return true
1019
+ })
1020
+ })
1021
+
1022
+ test('access-rule create rejects FieldVO member type in user scope', async () => {
1023
+ await assert.rejects(async () => {
1024
+ await executeCLI(
1025
+ 'admin',
1026
+ ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"rule","enabled":true,"type":"form","options":{"user_scope":[{"type":"member","id":2188889901}],"policy":{"key":"custom","actions":["view"],"all_fields_read":true,"all_fields_write":false},"list_options":{}}}'],
1027
+ env as any,
1028
+ async () => new Response('{}', { status: 200 }),
1029
+ )
1030
+ }, (error: unknown) => {
1031
+ assert.ok(error instanceof CLIError)
1032
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
1033
+ assert.match(error.message, /user.*department.*actor|FieldVO type "member"/)
1034
+ return true
1035
+ })
1036
+ })
1037
+
1038
+ test('access-rule create accepts custom policy with hidden field', async () => {
1039
+ const body = '{"name":"rule","enabled":true,"type":"form","options":{"user_scope":[{"type":"user","id":2188889901}],"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":true,"all_fields_write":true,"fields":[{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}'
1040
+ const out = await executeCLI(
1041
+ 'admin',
1042
+ ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', body],
1043
+ env as any,
1044
+ async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
1045
+ )
1046
+ assert.equal(out.kind, 'result')
1047
+ assert.deepEqual(out.data.options.policy.actions, ['view', 'add', 'edit'])
1048
+ assert.deepEqual(out.data.options.policy.fields, [{ key: ':1002', read: false, write: false, report: false }])
1049
+ })
1050
+
521
1051
  test('access-rule assign-users rejects users shortcut', async () => {
522
1052
  await assert.rejects(async () => {
523
1053
  await executeCLI(
@@ -555,29 +1085,29 @@ test('access-rule assign-users rejects top-level user_scope', async () => {
555
1085
  await assert.rejects(async () => {
556
1086
  await executeCLI(
557
1087
  'admin',
558
- ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"user_scope":[{"type":"member","id":123}]}'],
1088
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"user_scope":[{"type":"user","id":2188889901}]}'],
559
1089
  env as any,
560
1090
  async () => new Response('{}', { status: 200 }),
561
1091
  )
562
1092
  }, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
563
1093
  })
564
1094
 
565
- test('access-rule assign-users rejects non-member scope type', async () => {
1095
+ test('access-rule assign-users rejects non-user member scope type', async () => {
566
1096
  await assert.rejects(async () => {
567
1097
  await executeCLI(
568
1098
  'admin',
569
- ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":123}]}}'],
1099
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":2188889901}]}}'],
570
1100
  env as any,
571
1101
  async () => new Response('{}', { status: 200 }),
572
1102
  )
573
- }, (error: unknown) => assertAssignUsersGuidance(error, /type.*member/))
1103
+ }, (error: unknown) => assertAssignUsersGuidance(error, /type.*user/))
574
1104
  })
575
1105
 
576
1106
  test('access-rule assign-users rejects hash tenant user id', async () => {
577
1107
  await assert.rejects(async () => {
578
1108
  await executeCLI(
579
1109
  'admin',
580
- ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":"zW96OnE0kyJa3B2"}]}}'],
1110
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":"zW96OnE0kyJa3B2"}]}}'],
581
1111
  env as any,
582
1112
  async () => new Response('{}', { status: 200 }),
583
1113
  )
@@ -587,11 +1117,11 @@ test('access-rule assign-users rejects hash tenant user id', async () => {
587
1117
  test('access-rule assign-users accepts canonical user_scope body', async () => {
588
1118
  const out = await executeCLI(
589
1119
  'admin',
590
- ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}'],
1120
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}'],
591
1121
  env as any,
592
1122
  async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
593
1123
  )
594
1124
  assert.equal(out.kind, 'result')
595
1125
  assert.deepEqual(out.data.update, ['user_scope'])
596
- assert.deepEqual(out.data.options.user_scope, [{ type: 'member', id: 123 }])
1126
+ assert.deepEqual(out.data.options.user_scope, [{ type: 'user', id: 2188889901 }])
597
1127
  })