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 +2 -0
- package/lib/cli.js +377 -0
- package/lib/design-gates.js +18 -0
- package/lib/release-plan.js +2 -0
- package/openxiangda-skills/SKILL.md +2 -0
- package/openxiangda-skills/references/openxiangda-api.md +12 -0
- package/openxiangda-skills/references/resource-manifest-cheatsheet.md +32 -2
- package/openxiangda-skills/references/webhooks.md +213 -0
- package/openxiangda-skills/skills/openxiangda-workflow-automation/SKILL.md +3 -0
- package/package.json +23 -21
- package/templates/openxiangda-react-spa/.cursor/rules/openxiangda-resources.mdc +1 -0
- package/templates/openxiangda-react-spa/.qoder/rules/openxiangda-resources.md +1 -0
- package/templates/openxiangda-react-spa/AGENTS.md +2 -0
- package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda-resources.mdc +2 -0
- package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda-resources.md +1 -0
- package/templates/sy-lowcode-app-workspace/AGENTS.md +2 -0
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);
|
package/lib/design-gates.js
CHANGED
|
@@ -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;
|
package/lib/release-plan.js
CHANGED
|
@@ -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.
|
|
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
|
-
##
|
|
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.
|
|
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.
|
|
236
|
-
"title": "OpenXiangda V1
|
|
236
|
+
"version": "1.0.270",
|
|
237
|
+
"title": "OpenXiangda V1 Inbound Webhook",
|
|
237
238
|
"status": "reviewed",
|
|
238
|
-
"summary": "V1
|
|
239
|
+
"summary": "V1 应用可以声明公开 Inbound Webhook,由平台可靠接入并转发给固定 App Function,供应商验签和业务处理仍由应用负责。",
|
|
239
240
|
"newFeatures": [
|
|
240
|
-
"
|
|
241
|
-
"
|
|
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
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"新建工作区可保留受管登录文件;Git 基线只读检查使用隔离的工作区凭据副本。"
|
|
246
|
+
"Function 资源在 Webhook 之前发布,prune 对遗失的 Webhook 执行停用而不是删除审计数据。",
|
|
247
|
+
"模板、Skill、命令发现和 API 参考统一说明 rawBody 验签、secretRefs 和业务幂等要求。"
|
|
247
248
|
],
|
|
248
249
|
"affectedUsers": [
|
|
249
|
-
"
|
|
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": "
|
|
257
|
-
"platformPolicy": "
|
|
257
|
+
"workspacePolicy": "现有 V1 资源保持兼容;只有声明 webhooks 的应用会创建公开回调端点。",
|
|
258
|
+
"platformPolicy": "必须部署包含 V1 Webhook 管理/公开入口、SQL 迁移和网关规则的平台版本。App Function Secret 必须按部署规范启用。"
|
|
258
259
|
},
|
|
259
260
|
"upgradeSteps": [
|
|
260
|
-
"
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
"
|
|
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
|
-
"
|
|
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": "
|
|
271
|
-
"url": "https://github.com/1377385356/openxiangda-v1/releases/tag/v1.0.
|
|
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
|
## 工作区结构速查
|