@carthooks/arcubase-cli 0.1.20 → 0.1.21

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 (36) hide show
  1. package/bundle/arcubase-admin.mjs +339 -51
  2. package/bundle/arcubase.mjs +339 -51
  3. package/dist/generated/command_registry.generated.d.ts +15 -0
  4. package/dist/generated/command_registry.generated.d.ts.map +1 -1
  5. package/dist/generated/command_registry.generated.js +21 -0
  6. package/dist/generated/help_examples.generated.d.ts +114 -0
  7. package/dist/generated/help_examples.generated.d.ts.map +1 -1
  8. package/dist/generated/help_examples.generated.js +155 -0
  9. package/dist/generated/type_index.generated.d.ts +4 -0
  10. package/dist/generated/type_index.generated.d.ts.map +1 -1
  11. package/dist/generated/type_index.generated.js +4 -0
  12. package/dist/generated/zod_registry.generated.d.ts +4 -1
  13. package/dist/generated/zod_registry.generated.d.ts.map +1 -1
  14. package/dist/generated/zod_registry.generated.js +4 -0
  15. package/dist/runtime/execute.d.ts.map +1 -1
  16. package/dist/runtime/execute.js +104 -31
  17. package/dist/runtime/zod_registry.d.ts.map +1 -1
  18. package/dist/runtime/zod_registry.js +8 -0
  19. package/package.json +1 -1
  20. package/sdk-dist/api/admin/ingress.ts +11 -0
  21. package/sdk-dist/docs/runtime-reference/access-rule.md +19 -0
  22. package/sdk-dist/docs/runtime-reference/help-examples/admin/access-rule/bulk-apply.json +105 -0
  23. package/sdk-dist/generated/command_registry.generated.ts +21 -0
  24. package/sdk-dist/generated/help_examples.generated.ts +155 -0
  25. package/sdk-dist/generated/type_index.generated.ts +4 -0
  26. package/sdk-dist/generated/zod_registry.generated.ts +6 -0
  27. package/sdk-dist/types/ingress.ts +31 -0
  28. package/src/generated/command_registry.generated.ts +21 -0
  29. package/src/generated/help_examples.generated.ts +155 -0
  30. package/src/generated/type_index.generated.ts +4 -0
  31. package/src/generated/zod_registry.generated.ts +6 -0
  32. package/src/runtime/execute.ts +159 -51
  33. package/src/runtime/zod_registry.ts +8 -0
  34. package/src/tests/command_registry.test.ts +1 -0
  35. package/src/tests/execute_validation.test.ts +86 -0
  36. package/src/tests/help.test.ts +13 -0
@@ -637,6 +637,10 @@ function isAssignUsersCommand(scope: CommandScope, command: NonNullable<ReturnTy
637
637
  return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
638
638
  }
639
639
 
640
+ function isAccessRuleBulkApplyCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
641
+ return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'bulk-apply'
642
+ }
643
+
640
644
  function isTableCreateCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
641
645
  return scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create'
642
646
  }
@@ -1085,33 +1089,124 @@ function normalizeNumericAccessRuleUserScopeIDs(
1085
1089
  command: NonNullable<ReturnType<typeof findCommand>>,
1086
1090
  body: unknown,
1087
1091
  ): unknown {
1088
- if (!isAccessRuleCommand(scope, command) || !isRecord(body) || !isRecord(body.options) || !Array.isArray(body.options.user_scope)) {
1092
+ if (!isAccessRuleCommand(scope, command) || !isRecord(body)) {
1089
1093
  return body
1090
1094
  }
1091
1095
 
1092
- let changed = false
1093
- const userScope = body.options.user_scope.map((item) => {
1094
- if (!isRecord(item) || typeof item.id !== 'string' || !/^\d+$/.test(item.id)) {
1095
- return item
1096
- }
1097
- const id = Number(item.id)
1098
- if (!Number.isFinite(id) || !Number.isInteger(id)) {
1099
- return item
1100
- }
1101
- changed = true
1102
- return { ...item, id }
1103
- })
1096
+ const normalizeUserScope = (userScope: unknown): { value: unknown; changed: boolean } => {
1097
+ if (!Array.isArray(userScope)) return { value: userScope, changed: false }
1098
+ let changed = false
1099
+ const value = userScope.map((item) => {
1100
+ if (!isRecord(item) || typeof item.id !== 'string' || !/^\d+$/.test(item.id)) {
1101
+ return item
1102
+ }
1103
+ const id = Number(item.id)
1104
+ if (!Number.isFinite(id) || !Number.isInteger(id)) {
1105
+ return item
1106
+ }
1107
+ changed = true
1108
+ return { ...item, id }
1109
+ })
1110
+ return { value, changed }
1111
+ }
1112
+
1113
+ const normalizeNumericID = (value: unknown): { value: unknown; changed: boolean } => {
1114
+ if (typeof value !== 'string' || !/^\d+$/.test(value)) return { value, changed: false }
1115
+ const id = Number(value)
1116
+ if (!Number.isFinite(id) || !Number.isInteger(id)) return { value, changed: false }
1117
+ return { value: id, changed: true }
1118
+ }
1119
+
1120
+ if (isAccessRuleBulkApplyCommand(scope, command) && Array.isArray(body.items)) {
1121
+ let changed = false
1122
+ const items = body.items.map((item) => {
1123
+ if (!isRecord(item)) return item
1124
+ let nextItem: Record<string, unknown> = item
1125
+ for (const key of ['table_id', 'rule_id']) {
1126
+ const normalized = normalizeNumericID(nextItem[key])
1127
+ if (normalized.changed) {
1128
+ nextItem = { ...nextItem, [key]: normalized.value }
1129
+ changed = true
1130
+ }
1131
+ }
1132
+ if (isRecord(nextItem.options)) {
1133
+ const normalizedUserScope = normalizeUserScope(nextItem.options.user_scope)
1134
+ if (normalizedUserScope.changed) {
1135
+ nextItem = {
1136
+ ...nextItem,
1137
+ options: {
1138
+ ...nextItem.options,
1139
+ user_scope: normalizedUserScope.value,
1140
+ },
1141
+ }
1142
+ changed = true
1143
+ }
1144
+ }
1145
+ return nextItem
1146
+ })
1147
+ return changed ? { ...body, items } : body
1148
+ }
1104
1149
 
1105
- if (!changed) return body
1150
+ if (!isRecord(body.options)) {
1151
+ return body
1152
+ }
1153
+ const normalizedUserScope = normalizeUserScope(body.options.user_scope)
1154
+ if (!normalizedUserScope.changed) return body
1106
1155
  return {
1107
1156
  ...body,
1108
1157
  options: {
1109
1158
  ...body.options,
1110
- user_scope: userScope,
1159
+ user_scope: normalizedUserScope.value,
1111
1160
  },
1112
1161
  }
1113
1162
  }
1114
1163
 
1164
+ function validateAccessRulePolicyShape(
1165
+ scope: CommandScope,
1166
+ command: NonNullable<ReturnType<typeof findCommand>>,
1167
+ requestType: string,
1168
+ policy: Record<string, unknown> | undefined,
1169
+ env: EnvInput,
1170
+ pathPrefix: string,
1171
+ ) {
1172
+ const actions = policy && Array.isArray(policy.actions) ? policy.actions : []
1173
+ if (actions.includes('read') || actions.includes('write')) {
1174
+ throwBodyValidationFailure(
1175
+ 'access-rule policy.actions must use Arcubase action codes: use "view" for read and "add","edit" for write; do not use "read", "write", or "insert"',
1176
+ requestType,
1177
+ `${pathPrefix}.actions`,
1178
+ scope,
1179
+ command,
1180
+ env,
1181
+ )
1182
+ }
1183
+ const fields = policy && Array.isArray(policy.fields) ? policy.fields : []
1184
+ const allowedSystemFieldKeys = new Set(['created', 'updated', 'creator'])
1185
+ for (const [index, field] of fields.entries()) {
1186
+ if (!isRecord(field)) continue
1187
+ if (typeof field.key === 'number' || (typeof field.key === 'string' && /^\d+$/.test(field.key))) {
1188
+ throwBodyValidationFailure(
1189
+ 'access-rule policy.fields[].key must be a string prefixed with colon, for example ":1002"; do not use raw numbers',
1190
+ requestType,
1191
+ `${pathPrefix}.fields.${index}.key`,
1192
+ scope,
1193
+ command,
1194
+ env,
1195
+ )
1196
+ }
1197
+ if (typeof field.key === 'string' && !field.key.startsWith(':') && !allowedSystemFieldKeys.has(field.key)) {
1198
+ throwBodyValidationFailure(
1199
+ 'access-rule policy.fields[].key must use Arcubase permission keys such as ":1002"; do not use FieldVO.key values such as "vote_name"',
1200
+ requestType,
1201
+ `${pathPrefix}.fields.${index}.key`,
1202
+ scope,
1203
+ command,
1204
+ env,
1205
+ )
1206
+ }
1207
+ }
1208
+ }
1209
+
1115
1210
  function requireUpdateContains(
1116
1211
  scope: CommandScope,
1117
1212
  command: NonNullable<ReturnType<typeof findCommand>>,
@@ -1172,6 +1267,54 @@ function validateActionSpecificRawBody(
1172
1267
  }
1173
1268
 
1174
1269
  if (isAccessRuleCommand(scope, command)) {
1270
+ if (isAccessRuleBulkApplyCommand(scope, command)) {
1271
+ if (!Array.isArray(body.items) || body.items.length === 0) {
1272
+ throwBodyValidationFailure(
1273
+ 'access-rule bulk-apply requires non-empty body.items; put every table/rule assignment in body.items',
1274
+ command.requestType,
1275
+ 'body.items',
1276
+ scope,
1277
+ command,
1278
+ env,
1279
+ )
1280
+ }
1281
+ if (body.mode !== undefined && body.mode !== 'update_existing' && body.mode !== 'upsert_by_name') {
1282
+ throwBodyValidationFailure(
1283
+ 'access-rule bulk-apply body.mode must be "update_existing" or "upsert_by_name"',
1284
+ command.requestType,
1285
+ 'body.mode',
1286
+ scope,
1287
+ command,
1288
+ env,
1289
+ )
1290
+ }
1291
+ for (const [index, item] of body.items.entries()) {
1292
+ if (!isRecord(item)) {
1293
+ throwBodyValidationFailure(
1294
+ 'access-rule bulk-apply body.items[] must be an object',
1295
+ command.requestType,
1296
+ `body.items.${index}`,
1297
+ scope,
1298
+ command,
1299
+ env,
1300
+ )
1301
+ }
1302
+ const options = isRecord(item.options) ? item.options : undefined
1303
+ if (!options) {
1304
+ throwBodyValidationFailure(
1305
+ 'access-rule bulk-apply body.items[].options is required and must include the full canonical options object',
1306
+ command.requestType,
1307
+ `body.items.${index}.options`,
1308
+ scope,
1309
+ command,
1310
+ env,
1311
+ )
1312
+ }
1313
+ validateAccessRuleUserScope(scope, command, command.requestType, options.user_scope, env, `body.items.${index}.options.user_scope`)
1314
+ validateAccessRulePolicyShape(scope, command, command.requestType, isRecord(options.policy) ? options.policy : undefined, env, `body.items.${index}.options.policy`)
1315
+ }
1316
+ return
1317
+ }
1175
1318
  const update = stringArray(body.update)
1176
1319
  if (isAccessRuleUpdateCommand(scope, command) && update.some((item) => item.startsWith('options.'))) {
1177
1320
  throwBodyValidationFailure(
@@ -1216,42 +1359,7 @@ function validateActionSpecificRawBody(
1216
1359
  )
1217
1360
  }
1218
1361
  validateAccessRuleUserScope(scope, command, command.requestType, options?.user_scope, env)
1219
- const actions = policy && Array.isArray(policy.actions) ? policy.actions : []
1220
- if (actions.includes('read') || actions.includes('write')) {
1221
- throwBodyValidationFailure(
1222
- 'access-rule policy.actions must use Arcubase action codes: use "view" for read and "add","edit" for write; do not use "read", "write", or "insert"',
1223
- command.requestType,
1224
- 'body.options.policy.actions',
1225
- scope,
1226
- command,
1227
- env,
1228
- )
1229
- }
1230
- const fields = policy && Array.isArray(policy.fields) ? policy.fields : []
1231
- const allowedSystemFieldKeys = new Set(['created', 'updated', 'creator'])
1232
- for (const [index, field] of fields.entries()) {
1233
- if (!isRecord(field)) continue
1234
- if (typeof field.key === 'number' || (typeof field.key === 'string' && /^\d+$/.test(field.key))) {
1235
- throwBodyValidationFailure(
1236
- 'access-rule policy.fields[].key must be a string prefixed with colon, for example ":1002"; do not use raw numbers',
1237
- command.requestType,
1238
- `body.options.policy.fields.${index}.key`,
1239
- scope,
1240
- command,
1241
- env,
1242
- )
1243
- }
1244
- if (typeof field.key === 'string' && !field.key.startsWith(':') && !allowedSystemFieldKeys.has(field.key)) {
1245
- throwBodyValidationFailure(
1246
- 'access-rule policy.fields[].key must use Arcubase permission keys such as ":1002"; do not use FieldVO.key values such as "vote_name"',
1247
- command.requestType,
1248
- `body.options.policy.fields.${index}.key`,
1249
- scope,
1250
- command,
1251
- env,
1252
- )
1253
- }
1254
- }
1362
+ validateAccessRulePolicyShape(scope, command, command.requestType, policy, env, 'body.options.policy')
1255
1363
  }
1256
1364
  }
1257
1365
 
@@ -109,6 +109,10 @@ const docHintIndex: Record<string, RuntimeDocHint[]> = {
109
109
  { title: 'Access rule', file: 'docs/runtime-reference/access-rule.md' },
110
110
  examplesIndexDocHint,
111
111
  ],
112
+ AppIngressBulkApplyReqVO: [
113
+ { title: 'Access rule', file: 'docs/runtime-reference/access-rule.md' },
114
+ examplesIndexDocHint,
115
+ ],
112
116
  EntitySaveReqVO: [
113
117
  { title: 'Table schema cheatsheet', file: 'docs/runtime-reference/entity-schema.md' },
114
118
  { title: 'Field type index', file: 'docs/runtime-reference/entity-schema/README.md' },
@@ -225,6 +229,10 @@ const commandDocHintIndex: Record<string, RuntimeDocHint[]> = {
225
229
  { title: 'Access rule', file: 'docs/runtime-reference/access-rule.md' },
226
230
  examplesIndexDocHint,
227
231
  ],
232
+ 'admin.access-rule.bulkApplyEntityIngress': [
233
+ { title: 'Access rule', file: 'docs/runtime-reference/access-rule.md' },
234
+ examplesIndexDocHint,
235
+ ],
228
236
  'user.row.insertEntity': [
229
237
  { title: 'Row CRUD', file: 'docs/runtime-reference/row-crud.md' },
230
238
  { title: 'Uploads', file: 'docs/runtime-reference/uploads.md' },
@@ -24,6 +24,7 @@ test('admin surface is restricted to reset white list', () => {
24
24
  'table rename',
25
25
  'table update-schema',
26
26
  'access-rule assign-users',
27
+ 'access-rule bulk-apply',
27
28
  'access-rule create',
28
29
  'access-rule delete',
29
30
  'access-rule get',
@@ -1387,6 +1387,92 @@ test('access-rule assign-users accepts canonical mixed user_scope body', async (
1387
1387
  ])
1388
1388
  })
1389
1389
 
1390
+ test('access-rule bulk-apply normalizes numeric string ids before validation and request', async () => {
1391
+ const body = JSON.stringify({
1392
+ mode: 'update_existing',
1393
+ items: [
1394
+ {
1395
+ table_id: '2188893443',
1396
+ rule_id: '2188893557',
1397
+ enabled: true,
1398
+ options: {
1399
+ user_scope: [{ type: 'department', id: '2188881201' }],
1400
+ policy: { key: 'custom', actions: ['view'], all_fields_read: true, all_fields_write: false },
1401
+ list_options: {},
1402
+ },
1403
+ },
1404
+ ],
1405
+ })
1406
+ let requestedURL = ''
1407
+ const out = await executeCLI(
1408
+ 'admin',
1409
+ ['access-rule', 'bulk-apply', '--app-id', 'app_1', '--body-json', body],
1410
+ env as any,
1411
+ async (url, init) => {
1412
+ requestedURL = String(url)
1413
+ return new Response(String(init?.body ?? '{}'), { status: 200 })
1414
+ },
1415
+ )
1416
+ assert.equal(out.kind, 'result')
1417
+ assert.match(requestedURL, /\/api\/apps\/app_1\/access-rules\/bulk-apply$/)
1418
+ assert.equal(out.data.items[0].table_id, 2188893443)
1419
+ assert.equal(out.data.items[0].rule_id, 2188893557)
1420
+ assert.deepEqual(out.data.items[0].options.user_scope, [{ type: 'department', id: 2188881201 }])
1421
+ })
1422
+
1423
+ test('access-rule bulk-apply rejects empty items before request', async () => {
1424
+ await assert.rejects(async () => {
1425
+ await executeCLI(
1426
+ 'admin',
1427
+ ['access-rule', 'bulk-apply', '--app-id', 'app_1', '--body-json', '{"mode":"upsert_by_name","items":[]}'],
1428
+ env as any,
1429
+ async () => new Response('{}', { status: 200 }),
1430
+ )
1431
+ }, (error: unknown) => {
1432
+ assert.ok(error instanceof CLIError)
1433
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
1434
+ assert.equal(error.details?.requestType, 'AppIngressBulkApplyReqVO')
1435
+ assert.match(error.message, /non-empty body.items/)
1436
+ return true
1437
+ })
1438
+ })
1439
+
1440
+ test('access-rule bulk-apply rejects non-canonical policy actions before request', async () => {
1441
+ const body = JSON.stringify({
1442
+ mode: 'upsert_by_name',
1443
+ items: [
1444
+ {
1445
+ table_id: 2188893443,
1446
+ name: 'Bad read alias',
1447
+ enabled: true,
1448
+ options: {
1449
+ user_scope: [{ type: 'user', id: 2188889901 }],
1450
+ policy: { key: 'custom', actions: ['read'], all_fields_read: true, all_fields_write: false },
1451
+ list_options: {},
1452
+ },
1453
+ },
1454
+ ],
1455
+ })
1456
+ await assert.rejects(async () => {
1457
+ await executeCLI(
1458
+ 'admin',
1459
+ ['access-rule', 'bulk-apply', '--app-id', 'app_1', '--body-json', body],
1460
+ env as any,
1461
+ async () => new Response('{}', { status: 200 }),
1462
+ )
1463
+ }, (error: unknown) => {
1464
+ assert.ok(error instanceof CLIError)
1465
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
1466
+ assert.equal(error.details?.requestType, 'AppIngressBulkApplyReqVO')
1467
+ assert.match(error.message, /actions must use Arcubase action codes/)
1468
+ assert.deepEqual(error.details?.issues?.[0], {
1469
+ path: 'body.items.0.options.policy.actions',
1470
+ message: 'access-rule policy.actions must use Arcubase action codes: use "view" for read and "add","edit" for write; do not use "read", "write", or "insert"',
1471
+ })
1472
+ return true
1473
+ })
1474
+ })
1475
+
1390
1476
  test('access-rule create accepts nested custom policy condition with field columns', async () => {
1391
1477
  const body = JSON.stringify({
1392
1478
  name: 'Sales own or west region',
@@ -153,6 +153,19 @@ test('admin access-rule create help-examples show canonical ingress scopes and c
153
153
  assert.doesNotMatch(out.text, /"type":"member"/)
154
154
  })
155
155
 
156
+ test('admin access-rule bulk-apply help-examples show transactional matrix body', async () => {
157
+ const out = await executeCLI('admin', ['access-rule', 'bulk-apply', '--help-examples'], env as any, async () => new Response('{}'))
158
+ assert.equal(out.kind, 'help')
159
+ assert.match(out.text, /arcubase-admin access-rule bulk-apply --help-examples/)
160
+ assert.match(out.text, /"mode":"upsert_by_name"/)
161
+ assert.match(out.text, /"table_id":2188893443/)
162
+ assert.match(out.text, /"type":"department"/)
163
+ assert.match(out.text, /"type":"actor"/)
164
+ assert.match(out.text, /"value":"\[\[current_user\]\]"/)
165
+ assert.doesNotMatch(out.text, forbiddenArcubaseUserTerms())
166
+ assert.doesNotMatch(out.text, /"type":"member"/)
167
+ })
168
+
156
169
  test('user row bulk-update help-examples show ids and condition selection bodies', async () => {
157
170
  const out = await executeCLI('user', ['row', 'bulk-update', '--help-examples'], env as any, async () => new Response('{}'))
158
171
  assert.equal(out.kind, 'help')