openxiangda 1.0.269 → 1.0.270

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.
package/README.md CHANGED
@@ -617,6 +617,8 @@ const PublicAccessError = ({ error }: { error: { message?: string } }) => (
617
617
 
618
618
  App Function 访问第三方凭据时使用 `app_function_secrets_v1`:manifest 顶层只声明 `secretRefs: [{ "name": "dingtalk_org_app_key", "required": true }]`,同时使用 `definitionJson.version="function_v2"`、`runtimeContractVersion="trusted_node_v2"`;源码通过 `await ctx.secrets.get(name)` 解析,并通过 `ctx.utils.http` 访问受控公网 HTTPS(该桥接不会携带平台 Runtime token)。值只能经 `openxiangda secret create|rotate --value-stdin --change <id> --profile <name>` 或隐藏 TTY 输入,禁止进入 Git、manifest、源码、构建产物、plan、日志或异常。带 `secretRefs` 的 Function 必须走 `backend_release_v2`;需要整应用原子发布时先执行 `resource publish function --only <code> --stage-only`,再把返回的真实 `stagedResource` 交给 `release app-finalize --staged-resources-json ...` 完成 `atomic_staged_children_v2`。默认直接激活的 Backend Release 只返回 `activeResource`,不会伪装成 staged;旧平台 capability 不完整时 CLI 会失败关闭,绝不忽略绑定。
619
619
 
620
+ 外部系统主动调用应用时,在 `src/resources/webhooks/<code>.json` 声明 Inbound Webhook 和固定 `targetFunctionCode`,用 `openxiangda resource validate|plan|publish webhook` 发布。平台返回的 `callbackPath` 是公开回调路径;不要用租户内的 `appType` 自行拼接入口。目标 Function 在顶层 `secretRefs` 声明供应商 Secret,并且必须先基于精确 `input.rawBody` 验签,再访问表单、数据视图、连接器、通知或外部 HTTP;投递是 at-least-once,业务写入还要用 `input.idempotencyKey` 做原子幂等。使用 `openxiangda webhook deliveries|delivery` 查看投递状态和原始请求审计,完整契约见 `openxiangda-skills/references/webhooks.md`。
621
+
620
622
  平台部门和账号管理走 app-scoped organization 能力。只读查询要求目标应用的 `app:organization:read` 或 `app:organization:manage`,创建、更新和密码操作要求 `app:organization:manage`;平台管理员天然可用,普通应用角色需要显式授权。Runtime service principal 不会直接放行,`ctx.organization` 会按真实操作人 / audit actor 校验权限。新接口不提供删除;`account-update` 不能携带 `password`,重置他人密码必须走 `account-reset-password`,当前用户改密用 SDK / `ctx.organization.accounts.changeMyPassword({ oldPassword, newPassword })`。
621
623
 
622
624
  CLI 写操作必须加 `--force`:
package/lib/cli.js CHANGED
@@ -299,6 +299,7 @@ async function mainImpl(argv) {
299
299
  if (command === 'workflow') return workflow(rest);
300
300
  if (command === 'automation') return automation(rest);
301
301
  if (command === 'data-view') return dataView(rest);
302
+ if (command === 'webhook') return webhook(rest);
302
303
  if (command === 'scope') return scope(rest);
303
304
  if (command === 'route') return route(rest);
304
305
  if (command === 'public-access') return publicAccess(rest);
@@ -389,6 +390,7 @@ Usage:
389
390
  openxiangda automation diagnose <automationCode|automationId> [--redact] [--json]
390
391
  openxiangda automation publish|enable|disable <automationCode|automationId>
391
392
  openxiangda data-view list|get|create|update|upsert|delete|status|refresh|query|stats <dataViewCode> [--profile name] [--json]
393
+ openxiangda webhook list|get|create|update|upsert|disable|deliveries|delivery [webhookCode] [deliveryId] [--json-file file] [--json]
392
394
  openxiangda scope dimension-list|dimension-upsert|grant-source-list|grant-source-upsert|policy-list|policy-upsert|sync|explain [code] [--json-file file]
393
395
  openxiangda route list|get|create|update|upsert|delete [--json-file file] [--dry-run] [--write-manifest]
394
396
  openxiangda public-access list|get|create|update|upsert|delete|ticket-create|session-test|grant-check [--json-file file]
@@ -10357,6 +10359,7 @@ async function workspace(args) {
10357
10359
  connectors: {},
10358
10360
  dataViews: {},
10359
10361
  storageConfigs: {},
10362
+ webhooks: {},
10360
10363
  scopeDimensions: {},
10361
10364
  scopeGrantSources: {},
10362
10365
  dataScopePolicies: {},
@@ -12543,6 +12546,93 @@ async function dataView(args) {
12543
12546
  fail('用法: openxiangda data-view list|get|create|update|upsert|delete|status|refresh|query|stats');
12544
12547
  }
12545
12548
 
12549
+ async function webhook(args) {
12550
+ const { subcommand, rest } = parseSubcommandArgs(args);
12551
+ const { flags, positional } = parseArgs(rest);
12552
+ if (wantsSubcommandHelp(subcommand, flags)) {
12553
+ print('用法: openxiangda webhook list|get|create|update|upsert|disable|deliveries|delivery [webhookCode] [deliveryId] [--json-file file] [--json]');
12554
+ return;
12555
+ }
12556
+ const config = loadConfig();
12557
+ const target = getWorkspaceTarget(
12558
+ config,
12559
+ flags.profile || config.currentProfile,
12560
+ flags
12561
+ );
12562
+ const basePath = `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`;
12563
+
12564
+ if (['list', 'get', 'create', 'update', 'upsert'].includes(subcommand)) {
12565
+ return directResourceCrud(config, target, {
12566
+ commandName: 'webhook',
12567
+ subcommand,
12568
+ flags,
12569
+ positional,
12570
+ resourceType: 'webhook',
12571
+ basePath,
12572
+ manifestDir: path.join('src', 'resources', 'webhooks'),
12573
+ normalizeBody: normalizeWebhookManifest,
12574
+ codeOf: body => body.code || body.resourceCode,
12575
+ saveState: (code, data) => saveWebhookResource(target, code, data),
12576
+ });
12577
+ }
12578
+
12579
+ if (subcommand === 'disable') {
12580
+ const code = positional[0] || flags.code;
12581
+ if (!code) fail('用法: openxiangda webhook disable <webhookCode>');
12582
+ const body = readDirectJsonBody(flags, 'webhook disable', {
12583
+ optional: true,
12584
+ });
12585
+ if (flags['expected-revision'] !== undefined) {
12586
+ body.expectedRevision = Number(flags['expected-revision']);
12587
+ }
12588
+ const data = await runDirectRequest(
12589
+ config,
12590
+ target,
12591
+ flags,
12592
+ {
12593
+ method: 'POST',
12594
+ path: apiPathWithPostDeleteAction(
12595
+ `${basePath}/${encodeURIComponent(code)}`
12596
+ ),
12597
+ body,
12598
+ },
12599
+ { returnData: true }
12600
+ );
12601
+ if (!flags['dry-run']) saveWebhookResource(target, code, data);
12602
+ return outputDirectResult({ action: 'disable', data }, flags);
12603
+ }
12604
+
12605
+ if (subcommand === 'deliveries') {
12606
+ const code = positional[0] || flags.code;
12607
+ if (!code) fail('用法: openxiangda webhook deliveries <webhookCode>');
12608
+ return runDirectRequest(config, target, flags, {
12609
+ method: 'GET',
12610
+ path: apiPathWithQuery(
12611
+ `${basePath}/${encodeURIComponent(code)}/deliveries`,
12612
+ {
12613
+ page: flags.page,
12614
+ pageSize: flags['page-size'] || flags.limit,
12615
+ status: flags.status,
12616
+ }
12617
+ ),
12618
+ });
12619
+ }
12620
+
12621
+ if (subcommand === 'delivery') {
12622
+ const code = positional[0] || flags.code;
12623
+ const deliveryId = positional[1] || flags['delivery-id'];
12624
+ if (!code || !deliveryId) {
12625
+ fail('用法: openxiangda webhook delivery <webhookCode> <deliveryId>');
12626
+ }
12627
+ return runDirectRequest(config, target, flags, {
12628
+ method: 'GET',
12629
+ path: `${basePath}/${encodeURIComponent(code)}/deliveries/${encodeURIComponent(deliveryId)}`,
12630
+ });
12631
+ }
12632
+
12633
+ fail('用法: openxiangda webhook list|get|create|update|upsert|disable|deliveries|delivery');
12634
+ }
12635
+
12546
12636
  async function scope(args) {
12547
12637
  const { subcommand, rest } = parseSubcommandArgs(args);
12548
12638
  const { flags, positional } = parseArgs(rest);
@@ -17097,6 +17187,7 @@ async function commands(args) {
17097
17187
  'workflow compile|list|create|bind|pull|publish|delete|validate',
17098
17188
  'automation list|create|bind|pull|publish|unpublish|enable|disable|delete|validate|cron-validate',
17099
17189
  'data-view list|get|create|update|upsert|delete|status|refresh|query|stats',
17190
+ 'webhook list|get|create|update|upsert|disable|deliveries|delivery',
17100
17191
  'route list|get|create|update|upsert|delete',
17101
17192
  'public-access list|get|create|update|upsert|delete|ticket-create|session-test|grant-check',
17102
17193
  'auth-config list|get|create|update|upsert|delete|methods',
@@ -17119,6 +17210,7 @@ async function commands(args) {
17119
17210
  resourceNotes: [
17120
17211
  'For formal multi-resource development, prefer src/resources/** + openxiangda resource validate|plan|publish.',
17121
17212
  'Use first-class route/public-access/auth-config/function/connector/notification/data-view/menu/permission commands for discovery, diagnosis, dry-run, and small live fixes.',
17213
+ 'Inbound Webhooks are declared in src/resources/webhooks and route only to a fixed App Function. Provider secrets belong in top-level Function secretRefs; signature verification must use input.rawBody before any data access.',
17122
17214
  'public-access is the new React SPA public policy resource for /view/:appType/public/* routes; page code should use PublicAccessGate, and Page SDK hooks must stay inside OpenXiangdaProvider + OpenXiangdaPageProvider.',
17123
17215
  'settings public-access is legacy form public-access compatibility/repair and should not be used for new React SPA apps.',
17124
17216
  'Direct live mutation commands should use --dry-run first and --write-manifest when the repository should remain source of truth.',
@@ -17513,6 +17605,7 @@ function directManifestDir(resourceType) {
17513
17605
  connector: path.join('src', 'resources', 'connectors'),
17514
17606
  notification: path.join('src', 'resources', 'notifications'),
17515
17607
  'data-view': path.join('src', 'resources', 'data-views'),
17608
+ webhook: path.join('src', 'resources', 'webhooks'),
17516
17609
  menu: path.join('src', 'resources', 'menus'),
17517
17610
  role: path.join('src', 'resources', 'roles'),
17518
17611
  'page-permission-group': path.join('src', 'resources', 'permissions', 'page-groups'),
@@ -17532,6 +17625,7 @@ function removeDirectStateResource(target, resourceType, code) {
17532
17625
  function: 'functions',
17533
17626
  connector: 'connectors',
17534
17627
  'data-view': 'dataViews',
17628
+ webhook: 'webhooks',
17535
17629
  };
17536
17630
  const bucket = buckets[resourceType];
17537
17631
  if (!bucket || !target.bound.resources?.[bucket]?.[code]) return;
@@ -17854,6 +17948,7 @@ function ensureResourceBuckets(bound) {
17854
17948
  bound.resources.connectors = bound.resources.connectors || {};
17855
17949
  bound.resources.dataViews = bound.resources.dataViews || {};
17856
17950
  bound.resources.storageConfigs = bound.resources.storageConfigs || {};
17951
+ bound.resources.webhooks = bound.resources.webhooks || {};
17857
17952
  bound.resources.authConfigs = bound.resources.authConfigs || {};
17858
17953
  bound.resources.routes = bound.resources.routes || {};
17859
17954
  bound.resources.publicAccessPolicies = bound.resources.publicAccessPolicies || {};
@@ -18199,6 +18294,29 @@ function saveStorageConfigResource(target, storageCode, storageConfigId, extra =
18199
18294
  }, keys);
18200
18295
  }
18201
18296
 
18297
+ function saveWebhookResource(target, webhookCode, data = {}) {
18298
+ const keys = [
18299
+ 'webhookId',
18300
+ 'endpointId',
18301
+ 'callbackPath',
18302
+ 'callbackUrl',
18303
+ 'targetFunctionCode',
18304
+ 'idempotencyQueryParam',
18305
+ 'maxBodyBytes',
18306
+ 'status',
18307
+ 'revision',
18308
+ ];
18309
+ const callbackPath = String(data?.callbackPath || '').trim();
18310
+ const callbackUrl = callbackPath
18311
+ ? `${String(target.profile?.baseUrl || '').replace(/\/+$/, '')}${callbackPath}`
18312
+ : data?.callbackUrl;
18313
+ saveStateResource(target, 'webhooks', webhookCode, {
18314
+ ...pickStateFields(data, keys),
18315
+ webhookId: data?.id || data?.webhookId,
18316
+ callbackUrl,
18317
+ }, keys);
18318
+ }
18319
+
18202
18320
  function saveScopeResource(target, bucket, code, data = {}) {
18203
18321
  if (!bucket || !code) return;
18204
18322
  const keys = ['id', 'resourceCode', 'name', 'updatedAt'];
@@ -18347,6 +18465,7 @@ const RESOURCE_SPECS = [
18347
18465
  { key: 'functions', dir: 'functions', topFiles: ['functions.json'], pluralKeys: ['functions'] },
18348
18466
  { key: 'dataViews', dir: 'data-views', topFiles: ['data-views.json'], pluralKeys: ['dataViews', 'data-views'] },
18349
18467
  { key: 'storageConfigs', dir: 'storage', topFiles: ['storage.json'], pluralKeys: ['storageConfigs', 'storage'] },
18468
+ { key: 'webhooks', dir: 'webhooks', topFiles: ['webhooks.json'], pluralKeys: ['webhooks'] },
18350
18469
  { key: 'authConfigs', dir: 'auth', topFiles: ['auth.json'], pluralKeys: ['authConfigs', 'auth'] },
18351
18470
  { key: 'routes', dir: 'routes', topFiles: ['routes.json'], pluralKeys: ['routes'] },
18352
18471
  {
@@ -18426,6 +18545,8 @@ const RESOURCE_TYPE_ALIASES = new Map([
18426
18545
  ['oss', 'storageConfigs'],
18427
18546
  ['oss-config', 'storageConfigs'],
18428
18547
  ['oss-configs', 'storageConfigs'],
18548
+ ['webhook', 'webhooks'],
18549
+ ['webhooks', 'webhooks'],
18429
18550
  ['auth', 'authConfigs'],
18430
18551
  ['auth-config', 'authConfigs'],
18431
18552
  ['auth-configs', 'authConfigs'],
@@ -19138,6 +19259,7 @@ function generateResourceTypes(manifest, outputFile) {
19138
19259
  const dataViewCodes = unique((manifest.dataViews || []).map(item => item.code).filter(Boolean));
19139
19260
  const functionCodes = unique((manifest.functions || []).map(item => item.code).filter(Boolean));
19140
19261
  const storageConfigCodes = unique((manifest.storageConfigs || []).map(item => item.code).filter(Boolean));
19262
+ const webhookCodes = unique((manifest.webhooks || []).map(item => item.code).filter(Boolean));
19141
19263
  const authConfigCodes = unique((manifest.authConfigs || []).map(item => item.code).filter(Boolean));
19142
19264
  const publicAccessPolicyCodes = unique((manifest.publicAccessPolicies || []).map(item => item.code).filter(Boolean));
19143
19265
  const scopeDimensionCodes = unique((manifest.scopeDimensions || []).map(item => item.code).filter(Boolean));
@@ -19162,6 +19284,9 @@ function generateResourceTypes(manifest, outputFile) {
19162
19284
  `export const storageConfigCodes = ${JSON.stringify(storageConfigCodes, null, 2)} as const`,
19163
19285
  'export type StorageConfigCode = typeof storageConfigCodes[number]',
19164
19286
  '',
19287
+ `export const webhookCodes = ${JSON.stringify(webhookCodes, null, 2)} as const`,
19288
+ 'export type WebhookCode = typeof webhookCodes[number]',
19289
+ '',
19165
19290
  `export const functionCodes = ${JSON.stringify(functionCodes, null, 2)} as const`,
19166
19291
  'export type FunctionCode = typeof functionCodes[number]',
19167
19292
  '',
@@ -19196,6 +19321,7 @@ function generateResourceTypes(manifest, outputFile) {
19196
19321
  pagePermissionGroups: pagePermissionGroupCodes.length,
19197
19322
  dataViews: dataViewCodes.length,
19198
19323
  storageConfigs: storageConfigCodes.length,
19324
+ webhooks: webhookCodes.length,
19199
19325
  functions: functionCodes.length,
19200
19326
  authConfigs: authConfigCodes.length,
19201
19327
  publicAccessPolicies: publicAccessPolicyCodes.length,
@@ -19273,6 +19399,50 @@ function validateResourceItem(kind, item, errors, warnings) {
19273
19399
  validateFunctionRuntimeInvoke(label, item, errors, warnings);
19274
19400
  validateFunctionPerformancePolicies(label, item, errors);
19275
19401
  }
19402
+ if (kind === 'webhooks') {
19403
+ const allowed = new Set([
19404
+ 'code',
19405
+ 'resourceCode',
19406
+ 'name',
19407
+ 'description',
19408
+ 'targetFunctionCode',
19409
+ 'idempotencyQueryParam',
19410
+ 'maxBodyBytes',
19411
+ 'status',
19412
+ ]);
19413
+ const unsupported = Object.keys(item).filter(
19414
+ key => !key.startsWith('__') && !allowed.has(key)
19415
+ );
19416
+ if (unsupported.length > 0) {
19417
+ errors.push(`${label}: 包含不支持的字段 ${unsupported.join(', ')}`);
19418
+ }
19419
+ if (!item.name) errors.push(`${label}: 缺少 name`);
19420
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,127}$/.test(String(item.targetFunctionCode || ''))) {
19421
+ errors.push(`${label}: targetFunctionCode 格式不正确`);
19422
+ }
19423
+ if (
19424
+ item.idempotencyQueryParam !== undefined &&
19425
+ !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(
19426
+ String(item.idempotencyQueryParam)
19427
+ )
19428
+ ) {
19429
+ errors.push(`${label}: idempotencyQueryParam 格式不正确`);
19430
+ }
19431
+ const maxBodyBytes = Number(item.maxBodyBytes || 262144);
19432
+ if (
19433
+ !Number.isInteger(maxBodyBytes) ||
19434
+ maxBodyBytes < 1024 ||
19435
+ maxBodyBytes > 1048576
19436
+ ) {
19437
+ errors.push(`${label}: maxBodyBytes 必须在 1024 到 1048576 之间`);
19438
+ }
19439
+ if (!['active', 'disabled'].includes(String(item.status || 'active'))) {
19440
+ errors.push(`${label}: status 只能是 active 或 disabled`);
19441
+ }
19442
+ if (Object.keys(item).some(key => /secret|signature|hmac/i.test(key))) {
19443
+ errors.push(`${label}: Webhook manifest 不能包含 Secret 或供应商验签配置`);
19444
+ }
19445
+ }
19276
19446
  if (kind === 'dataViews') {
19277
19447
  const definition = item.definition || item;
19278
19448
  const viewType = String(definition.viewType || 'row').toLowerCase();
@@ -20384,6 +20554,7 @@ async function buildResourcePlan(
20384
20554
  await addAutomationPlanActions(config, target, actions, manifest.automations, existing.automations);
20385
20555
  addPlanActions(actions, 'dataView', manifest.dataViews, existing.dataViews, (item, current) => dataViewEquals(target.bound, item, current));
20386
20556
  addPlanActions(actions, 'storageConfig', manifest.storageConfigs, existing.storageConfigs, (item, current) => storageConfigEquals(item, current));
20557
+ addPlanActions(actions, 'webhook', manifest.webhooks, existing.webhooks, webhookEquals);
20387
20558
  addPlanActions(actions, 'scopeDimension', manifest.scopeDimensions, existing.scopeDimensions, (item, current) => scopeDimensionEquals(target.bound, item, current));
20388
20559
  addPlanActions(actions, 'scopeGrantSource', manifest.scopeGrantSources, existing.scopeGrantSources, (item, current) => scopeGrantSourceEquals(target.bound, item, current));
20389
20560
  addPlanActions(actions, 'dataScopePolicy', manifest.dataScopePolicies, existing.dataScopePolicies, dataScopePolicyEquals);
@@ -22244,6 +22415,7 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
22244
22415
  await publishDataScopePolicyResources(config, target, manifest.dataScopePolicies || [], result, publishOptions);
22245
22416
  await publishDataViewResources(config, target, manifest.dataViews || [], result, publishOptions);
22246
22417
  await publishStorageConfigResources(config, target, manifest.storageConfigs || [], result, publishOptions);
22418
+ await publishWebhookResources(config, target, manifest.webhooks || [], result, publishOptions);
22247
22419
  await publishPublicAccessPolicyResources(config, target, manifest.publicAccessPolicies || [], result, publishOptions);
22248
22420
  await publishPagePermissionGroupResources(config, target, manifest.pagePermissionGroups || [], result, publishOptions);
22249
22421
  await publishFormPermissionGroupResources(
@@ -22390,6 +22562,7 @@ const GENERIC_RESOURCE_KEY_BY_PLAN_KIND = Object.freeze({
22390
22562
  publicAccessPolicy: 'publicAccessPolicies',
22391
22563
  dataView: 'dataViews',
22392
22564
  storageConfig: 'storageConfigs',
22565
+ webhook: 'webhooks',
22393
22566
  scopeDimension: 'scopeDimensions',
22394
22567
  scopeGrantSource: 'scopeGrantSources',
22395
22568
  dataScopePolicy: 'dataScopePolicies',
@@ -22898,6 +23071,7 @@ async function fetchExistingResourceMaps(config, target, manifest) {
22898
23071
  automations: new Map(),
22899
23072
  dataViews: new Map(),
22900
23073
  storageConfigs: new Map(),
23074
+ webhooks: new Map(),
22901
23075
  scopeDimensions: new Map(),
22902
23076
  scopeGrantSources: new Map(),
22903
23077
  dataScopePolicies: new Map(),
@@ -23124,6 +23298,21 @@ async function fetchExistingResourceMaps(config, target, manifest) {
23124
23298
  });
23125
23299
  }
23126
23300
  }
23301
+ if ((manifest.webhooks || []).length > 0) {
23302
+ const data = await requestWithAuth(
23303
+ config,
23304
+ target.profileName,
23305
+ apiPathWithQuery(
23306
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`,
23307
+ { page: 1, pageSize: 1000 }
23308
+ )
23309
+ );
23310
+ indexByCode(
23311
+ maps.webhooks,
23312
+ normalizeItems(data),
23313
+ item => item.code || item.resourceCode
23314
+ );
23315
+ }
23127
23316
  if ((manifest.scopeDimensions || []).length > 0) {
23128
23317
  const data = await requestWithAuth(
23129
23318
  config,
@@ -25115,6 +25304,45 @@ async function publishStorageConfigResources(config, target, storageConfigs, res
25115
25304
  }
25116
25305
  }
25117
25306
 
25307
+ async function publishWebhookResources(config, target, webhooks, result, options = {}) {
25308
+ for (const webhookItem of webhooks) {
25309
+ const code = webhookItem.code || webhookItem.resourceCode;
25310
+ if (shouldSkipNoopResource(options, 'webhook', code)) {
25311
+ recordNoopResource(result, 'webhook', code, options);
25312
+ continue;
25313
+ }
25314
+ const existing = await findExistingWebhook(config, target, code);
25315
+ const existingRevision = Number(existing?.revision);
25316
+ const body = {
25317
+ ...normalizeWebhookManifest(webhookItem),
25318
+ expectedRevision:
25319
+ existing && Number.isInteger(existingRevision) && existingRevision > 0
25320
+ ? existingRevision
25321
+ : existing
25322
+ ? undefined
25323
+ : 0,
25324
+ };
25325
+ const data = await requestWithAuth(
25326
+ config,
25327
+ target.profileName,
25328
+ existing
25329
+ ? `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks/${encodeURIComponent(code)}`
25330
+ : `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`,
25331
+ { method: 'POST', body }
25332
+ );
25333
+ saveWebhookResource(target, code, data);
25334
+ result.published.push({
25335
+ kind: 'webhook',
25336
+ code,
25337
+ action: existing ? 'update' : 'create',
25338
+ id: data?.id,
25339
+ endpointId: data?.endpointId,
25340
+ callbackPath: data?.callbackPath,
25341
+ status: data?.status,
25342
+ });
25343
+ }
25344
+ }
25345
+
25118
25346
  async function publishDataViewPermissionGroups(config, target, dataViewCode, permissionGroups) {
25119
25347
  await requestWithAuth(
25120
25348
  config,
@@ -25279,6 +25507,25 @@ async function findExistingStorageConfig(config, target, code) {
25279
25507
  return null;
25280
25508
  }
25281
25509
 
25510
+ async function findExistingWebhook(config, target, code) {
25511
+ const state = target.bound.resources?.webhooks?.[code];
25512
+ const byCode = await requestOptionalWithAuth(
25513
+ config,
25514
+ target.profileName,
25515
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks/${encodeURIComponent(code)}`
25516
+ );
25517
+ if (byCode?.id) return byCode;
25518
+ if (state?.webhookId) {
25519
+ return {
25520
+ id: state.webhookId,
25521
+ code,
25522
+ revision: state.revision,
25523
+ endpointId: state.endpointId,
25524
+ };
25525
+ }
25526
+ return null;
25527
+ }
25528
+
25282
25529
  async function findExistingAutomation(config, target, code) {
25283
25530
  const stateId = target.bound.resources?.automations?.[code]?.automationId;
25284
25531
  if (stateId) {
@@ -25557,6 +25804,22 @@ async function collectPruneResourceCandidates(config, target, manifest) {
25557
25804
  item => item.code || item.resourceCode
25558
25805
  );
25559
25806
  }
25807
+ if (enabled('webhooks')) {
25808
+ const data = await requestWithAuth(
25809
+ config,
25810
+ target.profileName,
25811
+ apiPathWithQuery(`${appPath}/webhooks`, {
25812
+ page: 1,
25813
+ pageSize: 1000,
25814
+ })
25815
+ );
25816
+ append(
25817
+ 'webhook',
25818
+ desiredCodes(manifest.webhooks),
25819
+ normalizeItems(data),
25820
+ item => item.code || item.resourceCode
25821
+ );
25822
+ }
25560
25823
  if (enabled('publicAccessPolicies')) {
25561
25824
  const data = await requestWithAuth(
25562
25825
  config,
@@ -25747,6 +26010,21 @@ async function pruneFrozenResourceCandidate(config, target, result, candidate) {
25747
26010
  if (kind === 'storageConfig') {
25748
26011
  return postDelete(`${appPath}/storage-configs/${encodeURIComponent(code)}`);
25749
26012
  }
26013
+ if (kind === 'webhook') {
26014
+ return requestWithAuth(
26015
+ config,
26016
+ target.profileName,
26017
+ apiPathWithPostDeleteAction(
26018
+ `${appPath}/webhooks/${encodeURIComponent(code)}`
26019
+ ),
26020
+ {
26021
+ method: 'POST',
26022
+ body: candidate.frozenRevision
26023
+ ? { expectedRevision: candidate.frozenRevision }
26024
+ : {},
26025
+ }
26026
+ );
26027
+ }
25750
26028
  if (kind === 'publicAccessPolicy') {
25751
26029
  return postDelete(`${appPath}/public-access/policies/${encodeURIComponent(code)}`);
25752
26030
  }
@@ -26147,6 +26425,7 @@ function removeStateResource(target, kind, code) {
26147
26425
  publicAccessPolicy: 'publicAccessPolicies',
26148
26426
  dataView: 'dataViews',
26149
26427
  storageConfig: 'storageConfigs',
26428
+ webhook: 'webhooks',
26150
26429
  pagePermissionGroup: 'pagePermissionGroups',
26151
26430
  formPermissionGroup: 'formPermissionGroups',
26152
26431
  };
@@ -26449,6 +26728,22 @@ async function pullResources(config, target) {
26449
26728
  written.push(path.relative(process.cwd(), filePath));
26450
26729
  }
26451
26730
 
26731
+ const webhooks = await requestWithAuth(
26732
+ config,
26733
+ target.profileName,
26734
+ apiPathWithQuery(
26735
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/webhooks`,
26736
+ { page: 1, pageSize: 1000 }
26737
+ )
26738
+ );
26739
+ for (const webhookItem of normalizeItems(webhooks)) {
26740
+ const code = webhookItem.code || webhookItem.resourceCode || webhookItem.id;
26741
+ const filePath = path.join(baseDir, 'webhooks', `${code}.json`);
26742
+ writeResourceJsonFile(filePath, toPulledWebhook(webhookItem));
26743
+ written.push(path.relative(process.cwd(), filePath));
26744
+ saveWebhookResource(target, code, webhookItem);
26745
+ }
26746
+
26452
26747
  const pageGroups = await requestWithAuth(
26453
26748
  config,
26454
26749
  target.profileName,
@@ -26722,6 +27017,18 @@ function toPulledStorageConfig(storageConfig) {
26722
27017
  });
26723
27018
  }
26724
27019
 
27020
+ function toPulledWebhook(webhookItem) {
27021
+ return stripUndefinedValues({
27022
+ code: webhookItem.code || webhookItem.resourceCode,
27023
+ name: webhookItem.name,
27024
+ description: webhookItem.description || '',
27025
+ targetFunctionCode: webhookItem.targetFunctionCode,
27026
+ idempotencyQueryParam: webhookItem.idempotencyQueryParam || 'nonce',
27027
+ maxBodyBytes: webhookItem.maxBodyBytes || 262144,
27028
+ status: webhookItem.status || 'active',
27029
+ });
27030
+ }
27031
+
26725
27032
  function rewriteDataViewDefinitionForManifest(definition, lookups) {
26726
27033
  rewriteDataViewSourceForManifest(definition.base, lookups);
26727
27034
  for (const join of definition.joins || []) {
@@ -27174,6 +27481,61 @@ function normalizeStorageConfigManifest(storageConfig) {
27174
27481
  });
27175
27482
  }
27176
27483
 
27484
+ function normalizeWebhookManifest(webhook) {
27485
+ const code = String(webhook.code || webhook.resourceCode || '').trim();
27486
+ const allowed = new Set([
27487
+ 'code',
27488
+ 'resourceCode',
27489
+ 'name',
27490
+ 'description',
27491
+ 'targetFunctionCode',
27492
+ 'idempotencyQueryParam',
27493
+ 'maxBodyBytes',
27494
+ 'status',
27495
+ ]);
27496
+ const unsupported = Object.keys(webhook).filter(
27497
+ key => !key.startsWith('__') && !allowed.has(key)
27498
+ );
27499
+ if (unsupported.length > 0) {
27500
+ fail(`webhook:${code || '<unknown>'} 包含不支持的字段 ${unsupported.join(', ')}`);
27501
+ }
27502
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,127}$/.test(code)) {
27503
+ fail('Webhook code 格式不正确');
27504
+ }
27505
+ const targetFunctionCode = String(webhook.targetFunctionCode || '').trim();
27506
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,127}$/.test(targetFunctionCode)) {
27507
+ fail(`webhook:${code} targetFunctionCode 格式不正确`);
27508
+ }
27509
+ const idempotencyQueryParam = String(
27510
+ webhook.idempotencyQueryParam || 'nonce'
27511
+ ).trim();
27512
+ if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(idempotencyQueryParam)) {
27513
+ fail(`webhook:${code} idempotencyQueryParam 格式不正确`);
27514
+ }
27515
+ const maxBodyBytes = Number(webhook.maxBodyBytes || 262144);
27516
+ if (
27517
+ !Number.isInteger(maxBodyBytes) ||
27518
+ maxBodyBytes < 1024 ||
27519
+ maxBodyBytes > 1048576
27520
+ ) {
27521
+ fail(`webhook:${code} maxBodyBytes 必须在 1024 到 1048576 之间`);
27522
+ }
27523
+ const status = String(webhook.status || 'active');
27524
+ if (!['active', 'disabled'].includes(status)) {
27525
+ fail(`webhook:${code} status 只能是 active 或 disabled`);
27526
+ }
27527
+ return stripUndefinedValues({
27528
+ code,
27529
+ name: String(webhook.name || code).trim(),
27530
+ description:
27531
+ webhook.description === undefined ? '' : String(webhook.description),
27532
+ targetFunctionCode,
27533
+ idempotencyQueryParam,
27534
+ maxBodyBytes,
27535
+ status,
27536
+ });
27537
+ }
27538
+
27177
27539
  function normalizeStorageCorsForPublish(cors) {
27178
27540
  if (cors === undefined || cors === null || cors === '') return undefined;
27179
27541
  const managed =
@@ -28321,6 +28683,21 @@ function storageConfigEquals(desired, existing) {
28321
28683
  );
28322
28684
  }
28323
28685
 
28686
+ function webhookEquals(desired, existing) {
28687
+ if (!existing) return false;
28688
+ const expected = normalizeWebhookManifest(desired);
28689
+ return (
28690
+ String(existing.code || existing.resourceCode || '') === expected.code &&
28691
+ String(existing.name || '') === expected.name &&
28692
+ String(existing.description || '') === expected.description &&
28693
+ String(existing.targetFunctionCode || '') === expected.targetFunctionCode &&
28694
+ String(existing.idempotencyQueryParam || 'nonce') ===
28695
+ expected.idempotencyQueryParam &&
28696
+ Number(existing.maxBodyBytes || 262144) === expected.maxBodyBytes &&
28697
+ String(existing.status || 'active') === expected.status
28698
+ );
28699
+ }
28700
+
28324
28701
  function routeEquals(desired, existing) {
28325
28702
  if (!existing) return false;
28326
28703
  const expected = normalizeRouteManifest(desired);
@@ -540,6 +540,23 @@ const RESOURCE_EXPLAINS = {
540
540
  'openxiangda function invoke summarize_customer --body-json \'{"input":{}}\'',
541
541
  ],
542
542
  },
543
+ webhook: {
544
+ dir: 'src/resources/webhooks/*.json',
545
+ minimalManifest: {
546
+ code: 'yuquan_access',
547
+ name: '玉泉门禁开门事件',
548
+ targetFunctionCode: 'qfyy_access_event',
549
+ idempotencyQueryParam: 'nonce',
550
+ maxBodyBytes: 262144,
551
+ status: 'active',
552
+ },
553
+ commands: [
554
+ 'openxiangda resource validate webhook --profile <name>',
555
+ 'openxiangda resource plan webhook --only yuquan_access --profile <name> --json',
556
+ 'openxiangda resource publish webhook --only yuquan_access --change <id> --profile <name>',
557
+ 'openxiangda webhook deliveries yuquan_access --profile <name> --json',
558
+ ],
559
+ },
543
560
  connector: {
544
561
  dir: 'src/resources/connectors/*.json',
545
562
  minimalManifest: {
@@ -633,6 +650,7 @@ function getResourceExplain(type) {
633
650
  auth: 'auth-config',
634
651
  authconfigs: 'auth-config',
635
652
  functions: 'function',
653
+ webhooks: 'webhook',
636
654
  connectors: 'connector',
637
655
  };
638
656
  const key = aliases[rawKey] || rawKey;
@@ -102,6 +102,7 @@ const DIRECT_RESOURCE_TYPE_BY_KEY = Object.freeze({
102
102
  menus: 'menu',
103
103
  dataViews: 'data-view',
104
104
  storageConfigs: 'storage',
105
+ webhooks: 'webhook',
105
106
  authConfigs: 'auth-config',
106
107
  routes: 'route',
107
108
  publicAccessPolicies: 'public-access',
@@ -116,6 +117,7 @@ const DIRECT_RESOURCE_RELEASE_ORDER = Object.freeze([
116
117
  'roles',
117
118
  'connectors',
118
119
  'storageConfigs',
120
+ 'webhooks',
119
121
  'authConfigs',
120
122
  'routes',
121
123
  'publicAccessPolicies',
@@ -189,6 +189,8 @@ Treat resource declarations as environment mapping and release-observability met
189
189
 
190
190
  An App Function may declare metadata-only top-level `secretRefs: [{ name, required }]` only with `function_v2` + `trusted_node_v2`; source resolves values with `await ctx.secrets.get(name)` and uses `ctx.utils.http` for controlled public HTTPS. Create/rotate values through hidden TTY or `openxiangda secret ... --value-stdin --change <change> --profile <name>`. Never put values in arguments, files, manifests, state, plans, logs, errors, or chat. Secret bindings require `backend_release_v2` and whole-app `atomic_staged_children_v2`; a missing capability is fail-closed and never uses the legacy source PATCH. For whole-app activation use exact-scope `resource publish <type> --only <code> --stage-only`, then pass the returned verified `stagedResource` to `release app-finalize`; an active Backend Release is never labeled staged.
191
191
 
192
+ Inbound third-party callbacks use `src/resources/webhooks/<code>.json` and the public path returned after publish. A Webhook binds one fixed same-app Function; it never stores Secret values or provider signature rules. The Function reads the exact `input.rawBody`, verifies before any data/helper/network call, and protects business writes with `input.idempotencyKey`. Use `openxiangda webhook deliveries|delivery` for receipts and read `references/webhooks.md` through the workflow-automation skill for the full contract.
193
+
192
194
  The lease is app-level promotion ownership, while worktree ownership prevents two Codex tasks from editing through the same source directory. Different worktrees may keep developing and validating; only the clean synchronized main checkout publishes. Use full resource/runtime publish only when the approved bundle intentionally covers the whole dependency closure.
193
195
 
194
196
  ## Always
@@ -969,3 +969,15 @@ Requires Bearer token. Creates a short-lived signed OSS upload URL for browser d
969
969
  ### POST `/apps/:appType/storage-configs/:code/objects/delete`
970
970
 
971
971
  Requires Bearer token. Deletes an OSS object under the configured `pathPrefix`.
972
+ ## Inbound Webhook
973
+
974
+ - Management: `GET|POST /openxiangda-api/v1/apps/:appType/webhooks`
975
+ - Detail/update/disable: `GET|POST|PUT|DELETE /openxiangda-api/v1/apps/:appType/webhooks/:code`
976
+ - Delivery list/detail: `GET /openxiangda-api/v1/apps/:appType/webhooks/:code/deliveries[/:deliveryId]`
977
+ - Public callback: `POST /openxiangda-webhooks/v1/:endpointId`
978
+
979
+ The public `endpointId` resolves tenant/application ownership and is never
980
+ replaced with `appType`. Management calls use normal profile authentication;
981
+ the public callback is unauthenticated at the platform edge and the target
982
+ Function must verify the provider signature from the exact raw body. See
983
+ `webhooks.md` for the declaration and runtime envelope.
@@ -683,7 +683,37 @@ const result = await sdk.function.invoke("reservation_reminder_summary", {
683
683
 
684
684
  适用边界:可复用后端业务逻辑、跨页面/自动化/流程共享的查询编排、连接器调用、通知编排、受控平台 API 调用。App Function 支持 `ctx.form.queryOne/queryMany/getById/createOne/updateOne/updateById`、`ctx.dataView`、`ctx.connector`、`ctx.notification`、`ctx.platform.roles`、`ctx.platform.api` 等受控 helper,当前 MVP 不暴露原始 SQL/Redis。应用角色查询和成员维护优先使用 `ctx.platform.roles.list/findByCode/addUsers/removeUser`;底层 `ctx.platform.api` 返回 HTTP 包装与平台 envelope,需要自行解包。已发布可信代码可以访问当前租户、当前应用内的资源;function manifest 的 `resources` 是可选映射、审计和影响分析信息,不再是逐函数权限白名单。页面用户仍不能直接提交内部表单,跨应用和跨租户访问仍被拒绝。运行时接口默认需要应用自动化管理权限;普通用户页面要调用时,用 `definitionJson.runtimeInvoke.audience` 声明 `authenticated`、`page_permission_group`、`app_roles`、`platform_roles` 或 `scope_policy`,使用 `roleCodes` 匹配应用角色、使用 `platformRoleCodes` 匹配同步身份 `SCHOOL_GUARDIAN`、`SCHOOL_STUDENT`、`SCHOOL_TEACHER`,不要把 `"*"`、`"all-app-roles"` 写进角色编码。若表单只能由函数/流程写入,在 `src/resources/settings/forms/<formCode>.json` 设置 `runtimeWrite.mode="function_only"` 关闭原始写入接口。
685
685
 
686
- ## 5. Workflow — `src/resources/workflows/<code>/workflow.json`(manifest)+ `src/workflows/<code>/workflow.ts`(代码优先)
686
+ ## 5. Inbound Webhook — `src/resources/webhooks/<code>.json`
687
+
688
+ ```json
689
+ {
690
+ "code": "yuquan_access",
691
+ "name": "玉泉门禁开门事件",
692
+ "targetFunctionCode": "qfyy_access_event",
693
+ "idempotencyQueryParam": "nonce",
694
+ "maxBodyBytes": 262144,
695
+ "status": "active"
696
+ }
697
+ ```
698
+
699
+ Webhook 只声明公开入口到固定 App Function 的映射,不包含 Secret 或验签规则。
700
+ 供应商 Secret 在目标 Function 顶层 `secretRefs` 声明,源码通过
701
+ `await ctx.secrets.get(name)` 读取,并且必须在任何表单查询、写入、连接器或通知
702
+ 调用之前使用 `input.rawBody` 验签。平台保存原始 UTF-8 Body、Base64 Body、原始
703
+ Query 字符串、重复参数数组、解析 JSON 和安全请求头;投递为 at-least-once,应用
704
+ 还必须用 `input.idempotencyKey` 对业务写入做幂等保护。
705
+
706
+ ```bash
707
+ openxiangda resource validate webhook --profile <name>
708
+ openxiangda resource plan webhook --only yuquan_access --profile <name> --json
709
+ openxiangda resource publish webhook --only yuquan_access --change <id> --profile <name>
710
+ openxiangda webhook deliveries yuquan_access --profile <name> --json
711
+ ```
712
+
713
+ 完整 Function 输入、HMAC-SHA1 常量时间比较、返回状态和玉泉门禁示例见
714
+ [`webhooks.md`](webhooks.md)。
715
+
716
+ ## 6. Workflow — `src/resources/workflows/<code>/workflow.json`(manifest)+ `src/workflows/<code>/workflow.ts`(代码优先)
687
717
 
688
718
  ```jsonc
689
719
  // src/resources/workflows/customer_approval/workflow.json
@@ -713,7 +743,7 @@ export default defineWorkflow({
713
743
 
714
744
  CLI 编译为 `definition.v3.json` + `preview.json`,平台运行时仍走标准工作流引擎。完整规则见 [`workflow-v3.md`](workflow-v3.md)。
715
745
 
716
- ## 6. Automation — `src/resources/automations/<code>/{definition.code.json,preview.json}` + `src/automations/<code>/index.ts`
746
+ ## 7. Automation — `src/resources/automations/<code>/{definition.code.json,preview.json}` + `src/automations/<code>/index.ts`
717
747
 
718
748
  ```jsonc
719
749
  // src/resources/automations/notify_on_submit/definition.code.json
@@ -0,0 +1,213 @@
1
+ # OpenXiangda 1.x Inbound Webhooks
2
+
3
+ Inbound Webhook 用于让门禁、支付、IoT 等外部系统主动调用 OpenXiangda
4
+ 应用。平台只负责通用接入:接收和保存原始请求、限制大小、按幂等键串行化、
5
+ 调用唯一绑定的 App Function,并把 Function 的处理结果映射为稳定 HTTP 状态。
6
+ 供应商签名规则、事件筛选和业务写入始终属于应用 Function。
7
+
8
+ ## 声明 Webhook
9
+
10
+ 在 `src/resources/webhooks/<code>.json` 创建 Manifest:
11
+
12
+ ```json
13
+ {
14
+ "code": "yuquan_access",
15
+ "name": "玉泉门禁开门事件",
16
+ "description": "接收 REC_SUCCESS 事件并匹配琴房预约",
17
+ "targetFunctionCode": "qfyy_access_event",
18
+ "idempotencyQueryParam": "nonce",
19
+ "maxBodyBytes": 262144,
20
+ "status": "active"
21
+ }
22
+ ```
23
+
24
+ - `targetFunctionCode` 必须是同一租户、同一应用内已经启用的 Function。
25
+ - `idempotencyQueryParam` 默认是 `nonce`。该参数为空时,平台使用请求摘要。
26
+ - `maxBodyBytes` 默认 256 KiB,允许范围为 1 KiB 到 1 MiB。
27
+ - 平台网关对公开入口统一限制 1 MiB,并按来源 IP 限制 100 请求/秒、突发
28
+ 200;超限返回 429。该限制不代替验签和业务幂等。
29
+ - Manifest 不得包含 Webhook Secret、签名算法或供应商凭据。
30
+ - 发布后从 `.openxiangda/state.json` 的
31
+ `resources.webhooks.<code>.callbackUrl` 取得完整回调地址;`callbackPath` 是相对
32
+ 当前 profile API base(通常为 `<origin>/service`)的路径。不要自行用 `appType`
33
+ 拼接公开 URL。
34
+
35
+ ## 声明和配置 Secret
36
+
37
+ Webhook Secret 使用现有 App Function Secret。Function 必须使用
38
+ `function_v2 + trusted_node_v2`,并在 Manifest 顶层声明引用:
39
+
40
+ ```json
41
+ {
42
+ "code": "qfyy_access_event",
43
+ "name": "处理玉泉门禁事件",
44
+ "secretRefs": [
45
+ { "name": "yuquan_webhook_secret", "required": true }
46
+ ],
47
+ "definitionJson": {
48
+ "version": "function_v2",
49
+ "runtimeMode": "trusted_node",
50
+ "runtimeContractVersion": "trusted_node_v2",
51
+ "timeoutMs": 30000,
52
+ "sourceFile": {
53
+ "localPath": "src/functions/qfyy_access_event/index.ts"
54
+ }
55
+ },
56
+ "status": "active"
57
+ }
58
+ ```
59
+
60
+ Webhook Function 的 `timeoutMs` 必须在 1 到 120000 毫秒之间。平台在配置和
61
+ 每次调用时都会重新校验运行时契约,Function 后续被禁用、降级为旧运行时或把
62
+ 超时调到上限之外时,Webhook 会返回可重试的非 2xx,而不会以宽权限身份执行。
63
+
64
+ Secret 值只通过隐藏输入或标准输入写入平台:
65
+
66
+ ```bash
67
+ openxiangda secret create yuquan_webhook_secret \
68
+ --value-stdin --change <change-id> --profile <name>
69
+ ```
70
+
71
+ 禁止把值写进 Webhook/Function Manifest、源码、`.env`、日志、异常或函数返回值。
72
+
73
+ ## Function 输入
74
+
75
+ Function 的第二个参数和 `ctx.input` 都是以下对象:
76
+
77
+ ```ts
78
+ interface WebhookInput {
79
+ deliveryId: string;
80
+ idempotencyKey: string;
81
+ webhookCode: string;
82
+ receivedAt: string;
83
+ rawBody: string;
84
+ rawBodyBase64: string;
85
+ rawQueryString: string;
86
+ query: Record<string, string[]>;
87
+ headers: Record<string, string[]>;
88
+ body: unknown;
89
+ request: { method: "POST"; path: string };
90
+ }
91
+ ```
92
+
93
+ `query` 保留重复参数及其顺序。`rawBody` 是签名使用的原始 UTF-8 文本;
94
+ `body` 只用于验签成功后的业务解析。平台会从 `headers` 中移除 Authorization、
95
+ Cookie、代理凭据和客户端证书头。
96
+
97
+ ## 验签顺序
98
+
99
+ 验签、时间窗和事件来源检查必须发生在任何 `ctx.form`、`ctx.dataView`、
100
+ `ctx.connector`、`ctx.notification`、`ctx.platform` 或 `ctx.utils.http` 调用之前。
101
+ 永远不要对 `input.body` 再 `JSON.stringify` 后验签,空格、字段顺序或转义变化会
102
+ 使签名失真。
103
+
104
+ 下面展示 HMAC-SHA1 和常量时间比较。`buildCanonicalText` 只是结构示例;必须按
105
+ 供应商正式文档确认字段顺序、分隔符、URL 编码和 hex/base64 输出格式:
106
+
107
+ ```ts
108
+ import { createHmac, timingSafeEqual } from "node:crypto";
109
+ import type { AppFunctionContextV2 } from "openxiangda/runtime";
110
+
111
+ type WebhookInput = {
112
+ deliveryId: string;
113
+ idempotencyKey: string;
114
+ rawBody: string;
115
+ query: Record<string, string[]>;
116
+ body: unknown;
117
+ };
118
+
119
+ function first(query: Record<string, string[]>, name: string) {
120
+ return String(query[name]?.[0] || "");
121
+ }
122
+
123
+ function equalEncodedSignature(actual: string, expected: string) {
124
+ const left = Buffer.from(actual.trim().toLowerCase(), "utf8");
125
+ const right = Buffer.from(expected.trim().toLowerCase(), "utf8");
126
+ return left.length === right.length && timingSafeEqual(left, right);
127
+ }
128
+
129
+ function buildCanonicalText(input: WebhookInput) {
130
+ // Replace this illustrative order with the provider's exact contract.
131
+ return [
132
+ first(input.query, "nonce"),
133
+ first(input.query, "timestamp"),
134
+ first(input.query, "orgId"),
135
+ input.rawBody,
136
+ ].join("");
137
+ }
138
+
139
+ export default async function handleWebhook(
140
+ ctx: AppFunctionContextV2,
141
+ input: WebhookInput,
142
+ ) {
143
+ const secret = await ctx.secrets.get("yuquan_webhook_secret");
144
+ const expected = createHmac("sha1", secret)
145
+ .update(buildCanonicalText(input), "utf8")
146
+ .digest("hex");
147
+ const actual = first(input.query, "signature");
148
+ if (!actual || !equalEncodedSignature(actual, expected)) {
149
+ return { webhook: { outcome: "rejected", status: 401 } };
150
+ }
151
+
152
+ const timestamp = Number(first(input.query, "timestamp"));
153
+ if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > 5 * 60_000) {
154
+ return { webhook: { outcome: "rejected", status: 401 } };
155
+ }
156
+
157
+ const event = input.body as {
158
+ callbackTag?: string;
159
+ data?: {
160
+ isAuth?: boolean;
161
+ deviceSn?: string;
162
+ jobNum?: string;
163
+ recognizeTime?: string;
164
+ recognizeType?: number;
165
+ memberId?: string;
166
+ personType?: number;
167
+ };
168
+ };
169
+ if (event.callbackTag !== "REC_SUCCESS" || event.data?.isAuth !== true) {
170
+ return { webhook: { outcome: "ignored" } };
171
+ }
172
+
173
+ // The application must atomically claim input.idempotencyKey in its own
174
+ // business record before creating/updating reservation data.
175
+ await processAccessEventIdempotently(ctx, input.idempotencyKey, event.data);
176
+ return { webhook: { outcome: "accepted" } };
177
+ }
178
+ ```
179
+
180
+ `processAccessEventIdempotently` 是应用函数中的业务实现,不是平台内置 API。应把
181
+ `idempotencyKey` 写入带唯一约束或等价原子保护的业务记录,然后再执行副作用。
182
+ 平台提供 at-least-once 投递;如果 Function 已产生副作用,但进程在成功回执落库前
183
+ 中断,同一事件会再次执行。
184
+
185
+ ## 返回契约
186
+
187
+ - 普通返回,或 `{ webhook: { outcome: "accepted" } }`:HTTP 200。
188
+ - `{ webhook: { outcome: "ignored" } }`:HTTP 200,适合已验签但无需处理的事件。
189
+ - `{ webhook: { outcome: "rejected", status: 400|401|403|409|422 } }`:指定 4xx。
190
+ - `{ webhook: { outcome: "retry" } }` 或抛出异常:HTTP 503,通知供应商重试。
191
+
192
+ 平台不会把 Function 返回值、日志、异常详情或 Secret 回显给外部调用方。
193
+ 投递原始请求和审计信息默认保留 180 天,由平台部署环境统一配置;应用不应把
194
+ Webhook 投递表当作永久业务档案,需要长期保存的字段应在验签后写入自己的表单。
195
+
196
+ ## 校验、发布和诊断
197
+
198
+ ```bash
199
+ openxiangda resource validate webhook --profile <name>
200
+ openxiangda resource plan webhook --only yuquan_access --profile <name> --json
201
+ openxiangda resource publish webhook --only yuquan_access \
202
+ --change <change-id> --profile <name>
203
+
204
+ openxiangda webhook list --profile <name> --json
205
+ openxiangda webhook get yuquan_access --profile <name> --json
206
+ openxiangda webhook deliveries yuquan_access --profile <name> --json
207
+ openxiangda webhook delivery yuquan_access <delivery-id> --profile <name> --json
208
+ openxiangda webhook disable yuquan_access \
209
+ --change <change-id> --profile <name>
210
+ ```
211
+
212
+ 先发布并验证 Function,再启用 Webhook。新 Function 与 Webhook 同轮交付时,可以
213
+ 先以 `status: "disabled"` 发布 Webhook,验证 Function 后再把状态改为 `active`。
@@ -105,6 +105,8 @@ JS_CODE is the backend execution escape hatch for workflow and automation. Use i
105
105
 
106
106
  App Function is the reusable backend execution model. Use it when the logic should be called by custom pages, multiple automations, workflows, or the runtime API. Source lives in `src/functions/<functionCode>/index.ts`; manifest lives in `src/resources/functions/<functionCode>.json`. Call it from pages with `sdk.function.invoke(code, { input })`, from graph definitions with `function_call`, or from the runtime endpoint `/:appType/v1/functions/:code/invoke.json`. Direct runtime invocation defaults to app automation management permission; ordinary page callers must declare `definitionJson.runtimeInvoke.audience` (`authenticated`, `page_permission_group`, `app_roles`, or `scope_policy`). Prefer `export default async function(ctx, input) {}` for App Function source; the second argument is the invoke input and the same value is available as `ctx.input`. Current MVP exposes controlled helpers only and does not expose raw SQL or Redis. App Functions and trusted-node Automation scripts can call `ctx.files.readAsBase64` with an attachment from the current `ctx.formData`, or with a server-verified form record/field reference. This helper reads platform storage without a browser session, rejects arbitrary URLs and non-images, caps files at 10 MiB, and must be used without logging or persisting the Base64. App Functions can call `ctx.process.startFromExistingInstance`, `resolveCapabilities`, `resubmitTask`, `withdraw`, and `transferTask`; published trusted code may access resources in its current tenant/application, while workflow/task authorization, audit, event, replay, cross-app, and cross-tenant boundaries remain enforced. `resources` is optional mapping/audit metadata rather than an application-internal authorization list. Keep internal forms closed to direct user submit unless the business explicitly needs raw form submission; use `runtimeWrite.mode="function_only"` for function-only forms.
107
107
 
108
+ For inbound third-party callbacks, declare `src/resources/webhooks/<code>.json` with a fixed `targetFunctionCode`; do not expose `appType` as a public routing identifier and do not put provider verification fields or Secret values in the Webhook manifest. The Function must use `input.rawBody` for signature verification before any data/helper/network call, read only top-level declared `secretRefs`, and use `input.idempotencyKey` to protect business writes because delivery is at-least-once. Read `references/webhooks.md` for the complete envelope, HMAC example, response directives, publish commands, and delivery diagnosis.
109
+
108
110
  For third-party credentials, use top-level metadata-only `secretRefs` plus `definitionJson.version="function_v2"` and `runtimeContractVersion="trusted_node_v2"`; read a declared value with `await ctx.secrets.get(name)` and call public business APIs through `ctx.utils.http`. Never use `process.env` for platform secrets. Values are managed with `openxiangda secret create|rotate --value-stdin --change <change> --profile <name>` and never appear in Git, build output, snapshots, plan diffs, logs, exceptions, or traces. Local tests may use `function test --secret-from-env logical=ENV` only; the value is passed to an isolated child over stdin and is never written to workspace/cache/state. For a Root App transaction, use exact-scope `resource publish function --only <code> --stage-only` and include its verified `stagedResource` in `release app-finalize`.
109
111
 
110
112
  For new AI-authored automations, prefer code-first `automation_code_ts` resources instead of visual v3 graph definitions. Put the source in `src/automations/<resourceCode>/index.ts`, define `definition.code.json` with `kind: "automation_code_ts"`, and provide `preview.json` for read-only frontend display. Use `ctx.logger.debug/info/warn/error(message, data?)` at every important step; OpenXiangda can inspect logs with `automation executions`, `automation logs`, and `automation diagnose`.
@@ -199,3 +201,4 @@ Use `automation disable` before risky edits. Published automations create a draf
199
201
  - Notification resources and runtime calls: `references/notifications.md`
200
202
  - API fields: `references/openxiangda-api.md`
201
203
  - Profile-isolated IDs: `references/workspace-state.md`
204
+ - Inbound Webhook declaration, verification, idempotency, and delivery diagnosis: `references/webhooks.md`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.269",
3
+ "version": "1.0.270",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -68,7 +68,7 @@
68
68
  "source:admin-list": "node scripts/sync-admin-list-source.mjs",
69
69
  "check": "node --check bin/openxiangda.js && node --check lib/*.js && node --check packages/sdk/src/build-source/src/cli.mjs && node --check packages/sdk/src/build-source/scripts/*.mjs && node --check packages/sdk/src/build-source/scripts/utils/*.mjs",
70
70
  "test": "npm run test:fast",
71
- "test:fast": "npm run check && node scripts/delivery-v2-package-smoke.mjs && node scripts/delivery-v2-executor-smoke.mjs && node scripts/delivery-v2-release-conformance-smoke.mjs && node scripts/http-proxy-smoke.mjs && node scripts/form-export-cli-smoke.mjs && node scripts/open-api-cli-smoke.mjs && node scripts/release-plan-smoke.mjs && node scripts/typed-resource-plan-smoke.mjs && node scripts/integration-bundle-smoke.mjs && node scripts/content-cache-smoke.mjs && node scripts/dependency-capsule-smoke.mjs && node scripts/sdd-stages-smoke.mjs && node scripts/environment-swap-cli-smoke.mjs && node scripts/environment-policy-cli-smoke.mjs",
71
+ "test:fast": "npm run check && node scripts/delivery-v2-package-smoke.mjs && node scripts/delivery-v2-executor-smoke.mjs && node scripts/delivery-v2-release-conformance-smoke.mjs && node scripts/http-proxy-smoke.mjs && node scripts/form-export-cli-smoke.mjs && node scripts/open-api-cli-smoke.mjs && node scripts/release-plan-smoke.mjs && node scripts/typed-resource-plan-smoke.mjs && node scripts/webhook-resource-cli-smoke.mjs && node scripts/integration-bundle-smoke.mjs && node scripts/content-cache-smoke.mjs && node scripts/dependency-capsule-smoke.mjs && node scripts/sdd-stages-smoke.mjs && node scripts/environment-swap-cli-smoke.mjs && node scripts/environment-policy-cli-smoke.mjs",
72
72
  "test:changed": "npm run check && node scripts/run-test-suite.mjs --changed --evidence",
73
73
  "test:contract": "npm run check && node scripts/run-test-suite.mjs --contract --evidence",
74
74
  "test:release": "npm run check && node scripts/run-test-suite.mjs --release --evidence",
@@ -80,6 +80,7 @@
80
80
  "test:content-cache": "node scripts/content-cache-smoke.mjs",
81
81
  "test:dependency-capsule": "node scripts/dependency-capsule-smoke.mjs",
82
82
  "test:typed-resource-plan": "node scripts/typed-resource-plan-smoke.mjs",
83
+ "test:webhook-resource": "node scripts/webhook-resource-cli-smoke.mjs",
83
84
  "test:integration-bundle": "node scripts/integration-bundle-smoke.mjs",
84
85
  "prepublishOnly": "npm run release:evidence:verify && node scripts/release-mainline-guard.mjs",
85
86
  "prepack": "npm run source:admin-list && npm run build:sdk",
@@ -232,42 +233,43 @@
232
233
  },
233
234
  "openxiangdaRelease": {
234
235
  "schemaVersion": "openxiangda.release-notes/v1",
235
- "version": "1.0.269",
236
- "title": "OpenXiangda V1 工作区登录与 V2 升级建议",
236
+ "version": "1.0.270",
237
+ "title": "OpenXiangda V1 Inbound Webhook",
237
238
  "status": "reviewed",
238
- "summary": "V1 平台登录态按工作区隔离,停止读取和合并全局 profiles;版本、升级与技能刷新入口展示 V2 采用建议。",
239
+ "summary": "V1 应用可以声明公开 Inbound Webhook,由平台可靠接入并转发给固定 App Function,供应商验签和业务处理仍由应用负责。",
239
240
  "newFeatures": [
240
- "V1 人类可读命令展示 V2 推荐与迁移评估入口,JSON 结果提供结构化 migrationAdvice。",
241
- "login、auth 和 platform 支持 --cwd 指定登录态所属工作区。"
241
+ "新增 src/resources/webhooks/*.json 资源声明、校验、计划、发布、拉取和停用能力。",
242
+ "新增 webhook list/get/create/update/upsert/disable/deliveries/delivery 命令,用于配置和投递诊断。",
243
+ "Webhook Function 获得原始 Body、原始 Query、重复参数数组、安全 Headers、投递 ID 和幂等键。"
242
244
  ],
243
245
  "fixes": [
244
- "登录、刷新与退出读写工作区 .openxiangda/profiles.json,避免不同项目覆盖同一全局账号。",
245
- "会话对象固定来源文件,切换目录后仍写回原工作区;登录文件原子保存、权限 0600 并自动忽略 Git 跟踪。",
246
- "新建工作区可保留受管登录文件;Git 基线只读检查使用隔离的工作区凭据副本。"
246
+ "Function 资源在 Webhook 之前发布,prune 对遗失的 Webhook 执行停用而不是删除审计数据。",
247
+ "模板、Skill、命令发现和 API 参考统一说明 rawBody 验签、secretRefs 和业务幂等要求。"
247
248
  ],
248
249
  "affectedUsers": [
249
- "维护 V1 应用、在多个工作区使用不同平台或账号的开发者。"
250
+ "需要接收门禁、支付、IoT 或其他第三方主动回调的 OpenXiangda V1 应用开发者。"
250
251
  ],
251
252
  "compatibility": {
252
253
  "node": ">=18;全局 V2 统一入口需要 >=24",
253
254
  "workspaceGenerations": [
254
255
  "v1"
255
256
  ],
256
- "workspacePolicy": "原全局 profiles 保留但不再读取,升级后每个项目必须重新登录;已有工作区 profiles 可直接使用。",
257
- "platformPolicy": "不改变平台身份、授权协议或生产应用。OSS 环境配置仍由原全局 .env 管理。"
257
+ "workspacePolicy": "现有 V1 资源保持兼容;只有声明 webhooks 的应用会创建公开回调端点。",
258
+ "platformPolicy": "必须部署包含 V1 Webhook 管理/公开入口、SQL 迁移和网关规则的平台版本。App Function Secret 必须按部署规范启用。"
258
259
  },
259
260
  "upgradeSteps": [
260
- "将项目依赖锁定到 openxiangda@1.0.269 或运行同代 update install。",
261
- "进入项目重新运行 openxiangda login <platform-url>;新项目先用 --cwd <directory> 指定目标目录登录。",
262
- "执行 skill install --force 和 skill bootstrap --force 刷新技能与工作区指引,再运行项目检查。",
263
- "使用 Node.js 24 的统一入口查看 V2 迁移建议并执行 migrate assess,按评估结果规划应用迁移。"
261
+ "将 V1 项目依赖锁定到 openxiangda@1.0.270 或运行同代 update install。",
262
+ "执行 skill install --force 和 skill bootstrap --force,阅读 references/webhooks.md。",
263
+ "先发布声明 secretRefs 的 trusted_node_v2 Function,再声明并发布 Webhook。",
264
+ "从 .openxiangda/state.json 读取 callbackUrl,配置到供应商并用投递查询命令完成验签、重试和幂等验收。"
264
265
  ],
265
266
  "knownLimitations": [
266
- "不会自动复制全局或其他项目的令牌,也不会自动迁移 V1 应用到 V2。",
267
- "同一工作区并发登录或刷新仍受平台令牌有效性约束。"
267
+ "首版公开入口只接受 application/json 的 HTTP POST,单端点 Body 上限不超过 1 MiB。",
268
+ "平台不内置供应商签名算法;Function 必须按照供应商正式协议使用 rawBody 验签。",
269
+ "投递语义为 at-least-once,应用必须用 input.idempotencyKey 原子保护业务副作用。"
268
270
  ],
269
271
  "issues": [],
270
- "sha256": "112d003eb255dbdf9b221e6ec16c19fefe4a6e164bff22854e6128ad62df5d01",
271
- "url": "https://github.com/1377385356/openxiangda-v1/releases/tag/v1.0.269"
272
+ "sha256": "7ec9e6eebb0b509833283274bde2fdfba48a099c76cea862f77a08441a4ef1dd",
273
+ "url": "https://github.com/1377385356/openxiangda-v1/releases/tag/v1.0.270"
272
274
  }
273
275
  }
@@ -25,6 +25,7 @@ openxiangda resource typegen --profile <name>
25
25
  - Guest file upload requires a structured form grant such as `{code, actions: ["upload", "preview"], fields?: [...]}`; optional form `upload` or `grants.storage` constraints define bucket/MIME/extensions/size/visibility/path prefix.
26
26
  - Connector secrets and third-party credentials belong in the platform backend, never in manifests or page source.
27
27
  - App Function manifests may contain only metadata names in top-level `secretRefs`; values use the Secret CLI and runtime `ctx.secrets.get(name)` under `function_v2` / `trusted_node_v2`.
28
+ - Inbound callbacks use `src/resources/webhooks/*.json` with one fixed `targetFunctionCode`. Keep Secret/signature rules out of that manifest; verify `input.rawBody` before any helper call and make business writes idempotent with `input.idempotencyKey`.
28
29
  - Formal changes should keep Git as the source of truth: edit manifests, validate, plan, then publish.
29
30
  - Exact selectors apply before manifest/source analysis and JS_CODE build: touch only selected targets plus transitive/shared/ambient dependencies; omit selectors only for intentional full-workspace work.
30
31
  - `resource plan` and publish dry-runs are GET/HEAD-only. On `READ_ONLY_AUTH_REQUIRED`, run `openxiangda auth refresh --profile <name>` or log in again before retrying; never refresh inside the plan.
@@ -25,6 +25,7 @@ openxiangda resource typegen --profile <name>
25
25
  - Guest file upload requires a structured form grant such as `{code, actions: ["upload", "preview"], fields?: [...]}`; optional form `upload` or `grants.storage` constraints define bucket/MIME/extensions/size/visibility/path prefix.
26
26
  - Connector secrets and third-party credentials belong in the platform backend, never in manifests or page source.
27
27
  - App Function manifests may contain only metadata names in top-level `secretRefs`; values use the Secret CLI and runtime `ctx.secrets.get(name)` under `function_v2` / `trusted_node_v2`.
28
+ - Inbound callbacks use `src/resources/webhooks/*.json` with one fixed `targetFunctionCode`. Keep Secret/signature rules out of that manifest; verify `input.rawBody` before any helper call and make business writes idempotent with `input.idempotencyKey`.
28
29
  - Formal changes should keep Git as the source of truth: edit manifests, validate, plan, then publish.
29
30
  - Exact selectors apply before manifest/source analysis and JS_CODE build: touch only selected targets plus transitive/shared/ambient dependencies; omit selectors only for intentional full-workspace work.
30
31
  - `resource plan` and publish dry-runs are GET/HEAD-only. On `READ_ONLY_AUTH_REQUIRED`, run `openxiangda auth refresh --profile <name>` or log in again before retrying; never refresh inside the plan.
@@ -104,6 +104,8 @@ openxiangda release ship --change <release-change> --profile <name> --confirm-pr
104
104
 
105
105
  App Function 第三方凭据只能在 Function manifest 顶层声明 `secretRefs: [{name, required}]`,并使用 `function_v2` + `runtimeContractVersion: "trusted_node_v2"`;源码通过 `await ctx.secrets.get(name)` 获取。值只能经 `openxiangda secret create|rotate --value-stdin --change <id> --profile <name>` 或隐藏 TTY 配置,禁止进入 Git、`.env`、manifest、源码、构建产物、plan、日志和异常。本地测试只使用 `openxiangda function test --secret-from-env logical=ENV` 的隔离子进程注入。
106
106
 
107
+ 外部系统主动回调使用 `src/resources/webhooks/<code>.json`,只声明固定的 `targetFunctionCode`、幂等 Query 参数、Body 上限和启停状态。Secret 仍由目标 Function 顶层 `secretRefs` 声明;Function 必须先基于 `input.rawBody` 验签,再调用任何表单、数据视图、连接器、通知或 HTTP helper,并用 `input.idempotencyKey` 保护业务写入。完整输入和返回契约见 `$openxiangda-workflow-automation` 的 `references/webhooks.md`。
108
+
107
109
  App Function 查询应用角色及维护角色成员必须使用正式的 `ctx.platform.roles`:`findByCode(roleCode)`、`list()`、`get(roleId)`、`listUsers(roleId)`、`addUsers(roleId, userIds)`、`removeUser(roleId, userId)`。该 API 固定到 `ctx.app.appType`,按真实 operator 校验 `app:role:manage` 并保留调用审计;不要依赖自定义的 `ctx.platform.roles` 声明,也不要用泛型 `ctx.platform.api` 绕过角色契约。
108
110
 
109
111
  `openxiangda runtime deploy --no-activate` 会构建并上传不可变预览版本;发布前先提交所有可能进入构建的源码/配置。所有 Runtime deploy(包括 `--no-activate`)都会先获取应用发布 lease,并在任何构建和上传前冻结 clean `HEAD` 与当前 active Runtime 父血缘;旧分支返回 `RUNTIME_SOURCE_BASE_DIVERGED`,不能先上传旧 preview 再激活。`openspec/` SDD 证据和生成/状态目录不算源码 dirty。仅审批的回退可使用 `--allow-runtime-rollback --reason "至少 8 个字符"`;`--no-build` 不会跳过守卫。不要手工修改 `dist/index.html`。
@@ -31,6 +31,8 @@ Exact `--only/--code` selectors apply before manifest/source analysis and JS_COD
31
31
 
32
32
  App Function manifests may contain only logical names in top-level `secretRefs`; use `function_v2` / `trusted_node_v2`, resolve values with `ctx.secrets.get(name)`, and manage values only with `openxiangda secret ... --value-stdin --change ... --profile ...`. Values never belong in Git, `.env`, manifests, source, builds, plans, logs, or errors.
33
33
 
34
+ Inbound callbacks use `src/resources/webhooks/*.json` with one fixed `targetFunctionCode`. Keep Secret/signature rules out of that manifest; verify `input.rawBody` before any helper call and make business writes idempotent with `input.idempotencyKey`.
35
+
34
36
  When a Function/Automation enters scope only through source changes, publishing uses a server-side source-field PATCH and preserves online bindings, contracts, metadata, trigger/view configuration, and enabled/published state. A new source-free Automation with a complete `definitionJson.version="v3"` automatically uses manifest create; replacing an existing whole manifest requires `--replace-manifest --reason "..."`. Formal promotion freezes the clean publish HEAD separately from the change/remote baseline and preflights the whole set; `SOURCE_BASE_DIVERGED`, `RELEASE_SOURCE_BEHIND_MAIN`, and `RESOURCE_FIELD_CONFLICT` require reconciliation. After activation, merge/push the frozen SHA, verify `release integration-status`, then run normal `release end`.
35
37
 
36
38
  Before editing `roles`, `permissions/page-groups`, or `permissions/form-groups` for account/role/data-scope/RBAC/query-param authorization work, run `openxiangda design gates --topic permissions --json`, choose the permission mode, and write the permission matrix.
@@ -52,6 +52,7 @@ Function/Automation 仅因源码变化进入 scope 时,默认通过服务端
52
52
  - ❌ 把 `formUuid` / `pageId` / `workflowId` 等平台 ID 直接写进 manifest(CLI 解析逻辑 code)。
53
53
  - ❌ 把 API key / token / secret / password / authorization / headers / credential 写进 manifest(平台后台配置)。
54
54
  - ✅ App Function 只在 manifest 顶层写逻辑名 `secretRefs`,使用 `function_v2` / `trusted_node_v2` 和 `ctx.secrets.get(name)`;值仅由 `openxiangda secret ... --value-stdin --change ... --profile ...` 管理。
55
+ - ✅ 外部回调在 `src/resources/webhooks/*.json` 只绑定固定 `targetFunctionCode`;验签必须先使用 `input.rawBody`,业务写入必须使用 `input.idempotencyKey` 幂等,Secret/签名规则不写进 Webhook manifest。
55
56
  - ❌ data view 用作单表 CRUD、`linkedForm` 下拉、写回、强实时数据源。
56
57
  - ❌ 只靠 query 参数、前端隐藏按钮、硬编码角色、mock 权限或 `PermissionBoundary` 作为敏感授权。
57
58
  - ❌ 管理型角色缺少 `app:role:manage`、`app:page-permission-group:manage`、`app:form-permission-group:manage` 或 `app:organization:manage`。
@@ -118,6 +118,8 @@ Delivery V2 自动从期望状态按资源指纹计算精确范围,使用 CLI
118
118
 
119
119
  App Function 第三方凭据只能在 Function manifest 顶层声明 `secretRefs: [{name, required}]`,并使用 `function_v2` + `runtimeContractVersion: "trusted_node_v2"`;源码通过 `await ctx.secrets.get(name)` 获取。值只能用 `openxiangda secret create|rotate --value-stdin --change <id> --profile <name>` 或隐藏 TTY 配置,禁止写入 Git、`.env`、manifest、源码、构建产物、plan、日志和异常。本地联调只允许 `openxiangda function test --secret-from-env logical=ENV`,它不会把值写入 workspace/cache/state。
120
120
 
121
+ 外部系统主动回调使用 `src/resources/webhooks/<code>.json`,只声明固定的 `targetFunctionCode`、幂等 Query 参数、Body 上限和启停状态。Secret 仍由目标 Function 顶层 `secretRefs` 声明;Function 必须先基于 `input.rawBody` 验签,再调用任何表单、数据视图、连接器、通知或 HTTP helper,并用 `input.idempotencyKey` 保护业务写入。完整输入和返回契约见 `$openxiangda-workflow-automation` 的 `references/webhooks.md`。
122
+
121
123
  App Function 查询应用角色及维护角色成员必须使用正式的 `ctx.platform.roles`:`findByCode(roleCode)`、`list()`、`get(roleId)`、`listUsers(roleId)`、`addUsers(roleId, userIds)`、`removeUser(roleId, userId)`。该 API 固定到 `ctx.app.appType`,按真实 operator 校验 `app:role:manage` 并保留调用审计;不要依赖自定义的 `ctx.platform.roles` 声明,也不要用泛型 `ctx.platform.api` 绕过角色契约。
122
124
 
123
125
  ## 工作区结构速查