@synopackageland/cli 0.35.0 → 0.36.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.36.0 (2026-10-29)
4
+
5
+ - Stop treating `net.Listener` and `net.ListenConfig` types as listeners.
6
+ Actual constructors, aliases and declared listener/server methods remain
7
+ rejected. Accept SDK child-port reservation/probe, named private HTTP servers
8
+ and peer-credential executor transport as explicit boundaries.
9
+ - Add `audit-entry` for every custom-hook Go payload's own target import graph.
10
+ It never executes the App. Keep all files of imported shared packages in scope.
11
+ - Bind ordinary CGI/workers to IPC tier `none`; only separately approved root
12
+ helper builds may use executor-tier peer serving. Update the App skill for
13
+ SDK/App 0.34.0, named data planes, child ports and helper boundaries.
14
+
15
+ - Audit `(*api.Server).InvokeAsAdministrator` calls: each must name a literal
16
+ method declared `administrators` in permissions.json. Package commands outside
17
+ CGI use it instead of calling `ServeHTTP` on a server in process, which the
18
+ audit still rejects. Requires the SDK release with `InvokeAsAdministrator`.
19
+ - Accept the `integration` permission for Go `api.HandleIntegration` service
20
+ callbacks. `dsm-package.yaml` `integrations` must list exactly those methods.
21
+ The route audit requires the constructor and permission together, admits only
22
+ the callback's handler graph, and binds each `?method=` selector into the
23
+ HTTP route inventory. The install and upgrade wizards list the callbacks for
24
+ administrator approval; `inspect` verifies them. Requires the SDK release
25
+ with `api.HandleIntegration`.
26
+ - Stop the route audit from following a handler's receiver or parameter to
27
+ its type. A method value such as `cfg.serve` used to admit every method of
28
+ that type, which could hide an unregistered `ServeHTTP` dispatch elsewhere
29
+ on it.
30
+
3
31
  ## 0.35.0 (2026-10-29)
4
32
 
5
33
  - Audit the target Linux import graphs of configured Go binaries rather than
@@ -178,6 +178,12 @@ func boundaryHandlers(sources []source) map[ast.Node]bool {
178
178
  case *ast.Ident:
179
179
  key = s.pkg + "." + v.Name
180
180
  if v.Obj != nil {
181
+ // A parameter or receiver carries a value built elsewhere.
182
+ // Following its type would admit every method of that type,
183
+ // such as an App Config whose method value is the handler.
184
+ if _, param := v.Obj.Decl.(*ast.Field); param {
185
+ return true
186
+ }
181
187
  if d, ok := v.Obj.Decl.(ast.Node); ok {
182
188
  mark(d)
183
189
  }
@@ -196,6 +202,13 @@ func boundaryHandlers(sources []source) map[ast.Node]bool {
196
202
  for _, s := range sources {
197
203
  ast.Inspect(s.file, func(n ast.Node) bool {
198
204
  call, ok := n.(*ast.CallExpr)
205
+ if ok && len(call.Args) == 2 {
206
+ if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
207
+ if id, ok := sel.X.(*ast.Ident); ok && s.imports[id.Name] == sdkModule+"/api" && sel.Sel.Name == "HandleIntegration" {
208
+ mark(call.Args[1])
209
+ }
210
+ }
211
+ }
199
212
  if !ok || len(call.Args) != 3 {
200
213
  return true
201
214
  }
@@ -205,7 +218,7 @@ func boundaryHandlers(sources []source) map[ast.Node]bool {
205
218
  }
206
219
  id, ok := sel.X.(*ast.Ident)
207
220
  if ok && (s.imports[id.Name] == sdkModule+"/api" && sel.Sel.Name == "HandleHTTP" ||
208
- s.imports[id.Name] == sdkModule+"/ipc" && sel.Sel.Name == "ServeUnix") {
221
+ s.imports[id.Name] == sdkModule+"/ipc" && (sel.Sel.Name == "ServeUnix" || sel.Sel.Name == "StartUnix")) {
209
222
  mark(call.Args[2])
210
223
  }
211
224
  return true
@@ -300,6 +313,127 @@ func reusesBoundary(expr ast.Node, owner source, sources []source, safe map[ast.
300
313
  return found
301
314
  }
302
315
 
316
+ // Resolve declared receiver types without executing the App or requiring a
317
+ // successful type check (the audit also runs while projects are being edited).
318
+ func receiverType(expr ast.Node, src source, sources []source, seen map[ast.Node]bool) string {
319
+ if expr == nil || seen[expr] {
320
+ return ""
321
+ }
322
+ seen[expr] = true
323
+ follow := func(n ast.Node) string { return receiverType(n, src, sources, seen) }
324
+ find := func(pkg, name string) (ast.Node, source) {
325
+ for _, s := range sources {
326
+ if s.pkg != pkg {
327
+ continue
328
+ }
329
+ for _, decl := range s.file.Decls {
330
+ if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv == nil && fn.Name.Name == name {
331
+ return fn, s
332
+ }
333
+ if gen, ok := decl.(*ast.GenDecl); ok {
334
+ for _, spec := range gen.Specs {
335
+ if typ, ok := spec.(*ast.TypeSpec); ok && typ.Name.Name == name {
336
+ return typ, s
337
+ }
338
+ if value, ok := spec.(*ast.ValueSpec); ok {
339
+ for _, id := range value.Names {
340
+ if id.Name == name {
341
+ return id, s
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+ }
348
+ }
349
+ return nil, src
350
+ }
351
+ switch v := expr.(type) {
352
+ case *ast.ParenExpr:
353
+ return follow(v.X)
354
+ case *ast.StarExpr:
355
+ return follow(v.X)
356
+ case *ast.UnaryExpr:
357
+ return follow(v.X)
358
+ case *ast.CompositeLit:
359
+ return follow(v.Type)
360
+ case *ast.SelectorExpr:
361
+ if id, ok := v.X.(*ast.Ident); ok && src.imports[id.Name] != "" {
362
+ return src.imports[id.Name] + "." + v.Sel.Name
363
+ }
364
+ typ := follow(v.X)
365
+ if dot := strings.LastIndex(typ, "."); dot > 0 {
366
+ decl, owner := find(typ[:dot], typ[dot+1:])
367
+ if d, ok := decl.(*ast.TypeSpec); ok {
368
+ if st, ok := d.Type.(*ast.StructType); ok {
369
+ for _, field := range st.Fields.List {
370
+ for _, name := range field.Names {
371
+ if name.Name == v.Sel.Name {
372
+ return receiverType(field.Type, owner, sources, seen)
373
+ }
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ case *ast.Ident:
380
+ if v.Obj != nil {
381
+ switch d := v.Obj.Decl.(type) {
382
+ case *ast.Field:
383
+ return follow(d.Type)
384
+ case *ast.ValueSpec:
385
+ if d.Type != nil {
386
+ return follow(d.Type)
387
+ }
388
+ for i, name := range d.Names {
389
+ if name.Name == v.Name && i < len(d.Values) {
390
+ return follow(d.Values[i])
391
+ }
392
+ }
393
+ case *ast.AssignStmt:
394
+ for i, lhs := range d.Lhs {
395
+ if id, ok := lhs.(*ast.Ident); ok && id.Name == v.Name && i < len(d.Rhs) {
396
+ return follow(d.Rhs[i])
397
+ }
398
+ }
399
+ case *ast.TypeSpec:
400
+ if _, ok := d.Type.(*ast.StructType); ok {
401
+ return src.pkg + "." + d.Name.Name
402
+ }
403
+ return follow(d.Type)
404
+ }
405
+ } else {
406
+ decl, owner := find(src.pkg, v.Name)
407
+ return receiverType(decl, owner, sources, seen)
408
+ }
409
+ case *ast.TypeSpec:
410
+ if _, ok := v.Type.(*ast.StructType); ok {
411
+ return src.pkg + "." + v.Name.Name
412
+ }
413
+ return follow(v.Type)
414
+ case *ast.FuncDecl:
415
+ if v.Type.Results != nil && len(v.Type.Results.List) > 0 {
416
+ return follow(v.Type.Results.List[0].Type)
417
+ }
418
+ case *ast.CallExpr:
419
+ if id, ok := v.Fun.(*ast.Ident); ok && id.Name == "new" && len(v.Args) == 1 {
420
+ return follow(v.Args[0])
421
+ }
422
+ pkg, name := src.pkg, ""
423
+ switch fun := v.Fun.(type) {
424
+ case *ast.Ident:
425
+ name = fun.Name
426
+ case *ast.SelectorExpr:
427
+ if id, ok := fun.X.(*ast.Ident); ok {
428
+ pkg, name = src.imports[id.Name], fun.Sel.Name
429
+ }
430
+ }
431
+ decl, owner := find(pkg, name)
432
+ return receiverType(decl, owner, sources, seen)
433
+ }
434
+ return ""
435
+ }
436
+
303
437
  func main() {
304
438
  var in input
305
439
  if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil {
@@ -390,7 +524,7 @@ func main() {
390
524
  }
391
525
  ast.Inspect(file, func(n ast.Node) bool {
392
526
  if fn, ok := n.(*ast.FuncDecl); ok && fn.Recv != nil && fn.Name.Name == "ServeHTTP" && !safe[n] {
393
- problem(fn.Pos(), "unregistered ServeHTTP; use api.HandleHTTP or ipc.ServeUnix")
527
+ problem(fn.Pos(), "unregistered ServeHTTP; use api.HandleHTTP, api.HandleIntegration or ipc.ServeUnix")
394
528
  }
395
529
  selector, ok := n.(*ast.SelectorExpr)
396
530
  if !ok {
@@ -399,26 +533,37 @@ func main() {
399
533
  if selector.Sel.Name == "ServeHTTP" && !safe[n] {
400
534
  problem(selector.Pos(), "unregistered ServeHTTP dispatch")
401
535
  }
536
+ receiver := receiverType(selector.X, src, sources, map[ast.Node]bool{})
537
+ if receiver == "net.ListenConfig" && (selector.Sel.Name == "Listen" || selector.Sel.Name == "ListenPacket") ||
538
+ receiver == "net/http.Server" && (selector.Sel.Name == "Serve" || selector.Sel.Name == "ServeTLS" ||
539
+ selector.Sel.Name == "ListenAndServe" || selector.Sel.Name == "ListenAndServeTLS") {
540
+ problem(selector.Pos(), "custom listener outside SDK boundaries: "+selector.Sel.Name)
541
+ }
402
542
  pkg, ok := selector.X.(*ast.Ident)
403
543
  if !ok {
404
544
  return true
405
545
  }
406
546
  module := imports[pkg.Name]
407
547
  name := selector.Sel.Name
408
- if module == "net" && (strings.HasPrefix(name, "Listen") || name == "FileListener" || name == "FilePacketConn") ||
548
+ if module == "net" && (name == "Listen" || name == "ListenTCP" || name == "ListenUnix" ||
549
+ name == "ListenPacket" || name == "ListenUDP" || name == "ListenIP" || name == "ListenUnixgram" ||
550
+ name == "FileListener" || name == "FilePacketConn") ||
409
551
  module == "crypto/tls" && name == "Listen" ||
410
552
  (module == "syscall" || module == "golang.org/x/sys/unix") && (name == "Bind" || name == "Listen" || name == "Socket") {
411
553
  problem(selector.Pos(), "custom listener outside SDK boundaries: "+name)
412
554
  }
413
- if module == sdkModule+"/ipc" && name == "ServeUnix" && !direct[selector] {
414
- problem(selector.Pos(), "IPC constructor aliases cannot be audited")
555
+ if module == sdkModule+"/ipc" && !direct[selector] {
556
+ switch name {
557
+ case "ServeUnix", "StartUnix", "ServePeerUnix", "DialUnix", "DialPeerUnix", "ReserveLoopback", "ProbeLoopback":
558
+ problem(selector.Pos(), "IPC constructor aliases cannot be audited")
559
+ }
415
560
  }
416
561
  if module == "github.com/synopackageland/syno-pkg-land-sdk/idgateway" && (name == "NewFront" || name == "Build" || name == "Run") {
417
562
  problem(selector.Pos(), "gateway listeners must use identity_gateway in the manifest; upstream_auth_paths never exempts SDK RPC or custom HTTP handlers")
418
563
  }
419
564
  if module == "github.com/synopackageland/syno-pkg-land-sdk/api" {
420
565
  switch name {
421
- case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch", "HandleHTTP":
566
+ case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch", "HandleHTTP", "HandleIntegration":
422
567
  if !direct[selector] {
423
568
  problem(selector.Pos(), "route constructor aliases cannot be audited; register methods directly")
424
569
  }
@@ -426,7 +571,7 @@ func main() {
426
571
  }
427
572
  if module == "net/http" {
428
573
  switch name {
429
- case "Handle", "HandleFunc", "Server", "Serve", "ServeTLS", "ListenAndServe", "ListenAndServeTLS":
574
+ case "Handle", "HandleFunc", "Serve", "ServeTLS", "ListenAndServe", "ListenAndServeTLS":
430
575
  problem(selector.Pos(), "HTTP handler outside the SDK registry: "+name)
431
576
  case "HandlerFunc", "NewServeMux", "ServeMux", "FileServer", "FileServerFS", "StripPrefix", "TimeoutHandler", "NotFoundHandler", "RedirectHandler":
432
577
  if !safe[n] {
@@ -453,6 +598,26 @@ func main() {
453
598
  if !ok {
454
599
  return true
455
600
  }
601
+ // An in-process administrator call names one fixed route that
602
+ // already requires DSM administrators; the SDK verifies the account.
603
+ if selector.Sel.Name == "InvokeAsAdministrator" {
604
+ method := ""
605
+ if len(call.Args) == 4 {
606
+ switch arg := call.Args[2].(type) {
607
+ case *ast.BasicLit:
608
+ if arg.Kind == token.STRING {
609
+ method, _ = strconv.Unquote(arg.Value)
610
+ }
611
+ case *ast.Ident:
612
+ method = constants[arg.Name]
613
+ }
614
+ }
615
+ if method == "" {
616
+ problem(call.Pos(), "dynamic InvokeAsAdministrator method cannot be audited; name a literal method")
617
+ } else if in.Permissions[method] != "administrators" {
618
+ problem(call.Pos(), "InvokeAsAdministrator requires a method declared administrators in permissions.json: "+method)
619
+ }
620
+ }
456
621
  pkg, ok := selector.X.(*ast.Ident)
457
622
  if !ok {
458
623
  return true
@@ -468,7 +633,7 @@ func main() {
468
633
  return true
469
634
  }
470
635
  switch selector.Sel.Name {
471
- case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch", "HandleHTTP":
636
+ case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch", "HandleHTTP", "HandleIntegration":
472
637
  default:
473
638
  return true
474
639
  }
@@ -492,6 +657,14 @@ func main() {
492
657
  } else if _, declared := in.Permissions[method]; !declared && !sdk[method] {
493
658
  problem(call.Pos(), "registered method missing from permissions.json: "+method)
494
659
  }
660
+ // Only HandleIntegration skips the DSM session; the permission
661
+ // value and the constructor must agree in both directions.
662
+ if (selector.Sel.Name == "HandleIntegration") != (in.Permissions[method] == "integration") {
663
+ problem(call.Pos(), "the integration permission requires api.HandleIntegration, and api.HandleIntegration requires the integration permission")
664
+ }
665
+ if selector.Sel.Name == "HandleIntegration" && method != "" {
666
+ out.HTTPRoutes = append(out.HTTPRoutes, httpRoute{"?method=" + method, method})
667
+ }
495
668
  if selector.Sel.Name == "HandleHTTP" {
496
669
  prefix := ""
497
670
  if len(call.Args) > 0 {
@@ -16,5 +16,7 @@ export interface InstallWizardFiles {
16
16
  * items, and only the dynamic one adds the reuse notice when data exists.
17
17
  */
18
18
  export declare function renderInstallWizardFiles(packageName: string, render: (t: Translate) => InstallWizardStep[]): InstallWizardFiles;
19
+ /** Lists the App's unauthenticated service callbacks for the administrator. */
20
+ export declare function integrationWizardSteps(t: Translate, methods?: readonly string[]): InstallWizardStep[];
19
21
  /** The native-go install step: no parameters, only the app-space consent. */
20
22
  export declare function renderNativeInstallWizard(t: Translate): InstallWizardStep[];
@@ -70,6 +70,10 @@ export function renderInstallWizardFiles(packageName, render) {
70
70
  }
71
71
  return { static: staticFiles, dynamic };
72
72
  }
73
+ /** Lists the App's unauthenticated service callbacks for the administrator. */
74
+ export function integrationWizardSteps(t, methods = []) {
75
+ return methods.length ? [{ step_title: t("integration.step"), items: [{ desc: t("integration.notice", { methods: methods.join(", ") }) }] }] : [];
76
+ }
73
77
  /** The native-go install step: no parameters, only the app-space consent. */
74
78
  export function renderNativeInstallWizard(t) {
75
79
  return [{ step_title: t("install.step"), items: [{ desc: t("consent.appOwnSpace") }] }];
@@ -230,6 +230,28 @@ export declare const WIZARD_MESSAGES: {
230
230
  es: string;
231
231
  it: string;
232
232
  };
233
+ "integration.step": {
234
+ en: string;
235
+ "zh-TW": string;
236
+ "zh-CN": string;
237
+ ja: string;
238
+ ko: string;
239
+ de: string;
240
+ fr: string;
241
+ es: string;
242
+ it: string;
243
+ };
244
+ "integration.notice": {
245
+ en: string;
246
+ "zh-TW": string;
247
+ "zh-CN": string;
248
+ ja: string;
249
+ ko: string;
250
+ de: string;
251
+ fr: string;
252
+ es: string;
253
+ it: string;
254
+ };
233
255
  "install.reuseData": {
234
256
  en: string;
235
257
  "zh-TW": string;
@@ -230,6 +230,28 @@ export const WIZARD_MESSAGES = {
230
230
  es: "Configuración de instalación",
231
231
  it: "Impostazioni di installazione",
232
232
  },
233
+ "integration.step": {
234
+ en: "Service callbacks",
235
+ "zh-TW": "服務回呼入口",
236
+ "zh-CN": "服务回调入口",
237
+ ja: "サービスコールバック",
238
+ ko: "서비스 콜백",
239
+ de: "Dienst-Callbacks",
240
+ fr: "Rappels de services",
241
+ es: "Devoluciones de llamada de servicios",
242
+ it: "Callback dei servizi",
243
+ },
244
+ "integration.notice": {
245
+ en: "These app entry points accept requests from other services without a DSM sign-in: {methods}. The app verifies each request with its own token, signature or key. It acts only for DSM accounts that are enabled and allowed by Application Privileges, and never with administrator rights.",
246
+ "zh-TW": "這些 App 入口接受其他服務在未登入 DSM 的情況下送來的請求:{methods}。App 會用自己的 token、簽章或金鑰驗證每個請求,只會代表已啟用且 Application Privileges 允許的 DSM 帳號執行,且一律不具管理員權限。",
247
+ "zh-CN": "这些应用入口接受其他服务在未登录 DSM 的情况下发送的请求:{methods}。应用会用自己的 token、签名或密钥验证每个请求,只会代表已启用且 Application Privileges 允许的 DSM 账号执行,且始终不具备管理员权限。",
248
+ ja: "次のアプリの入口は、DSM にサインインしていない他のサービスからの要求を受け付けます:{methods}。アプリは各要求を独自のトークン、署名、またはキーで検証します。有効で、アプリケーション権限で許可された DSM アカウントとしてのみ動作し、管理者権限は使いません。",
249
+ ko: "다음 앱 진입점은 DSM에 로그인하지 않은 다른 서비스의 요청을 받습니다: {methods}. 앱은 자체 토큰, 서명 또는 키로 각 요청을 확인합니다. 활성화되어 있고 애플리케이션 권한이 허용하는 DSM 계정으로만 동작하며, 관리자 권한은 사용하지 않습니다.",
250
+ de: "Diese App-Einstiegspunkte nehmen Anfragen anderer Dienste ohne DSM-Anmeldung an: {methods}. Die App prüft jede Anfrage mit eigenem Token, eigener Signatur oder eigenem Schlüssel. Sie handelt nur für aktivierte DSM-Konten, die die Anwendungsberechtigungen zulassen, und nie mit Administratorrechten.",
251
+ fr: "Ces points d'entrée de l'application acceptent des requêtes d'autres services sans connexion DSM : {methods}. L'application vérifie chaque requête avec son propre jeton, sa signature ou sa clé. Elle n'agit que pour des comptes DSM activés et autorisés par les privilèges d'application, et jamais avec des droits d'administrateur.",
252
+ es: "Estos puntos de entrada de la aplicación aceptan solicitudes de otros servicios sin iniciar sesión en DSM: {methods}. La aplicación verifica cada solicitud con su propio token, firma o clave. Solo actúa en nombre de cuentas de DSM habilitadas y permitidas por los privilegios de aplicación, y nunca con derechos de administrador.",
253
+ it: "Questi punti di ingresso dell'app accettano richieste da altri servizi senza accesso a DSM: {methods}. L'app verifica ogni richiesta con il proprio token, firma o chiave. Agisce solo per account DSM abilitati e consentiti dai privilegi delle applicazioni, mai con diritti di amministratore.",
254
+ },
233
255
  "install.reuseData": {
234
256
  en: "Data from an earlier installation is still on this NAS (@appdata/{package}), so this install reuses it: saved settings and generated secrets stay. To start empty, cancel and clear that data first — uninstall the app with “Also delete the app data listed above” ticked — then install again.",
235
257
  "zh-TW": "這台 NAS 還留有先前安裝的資料(@appdata/{package}),本次安裝會沿用:已存的設定與產生的 secrets 都會保留。若要從空白開始,請先取消並清除該資料——解除安裝時勾選「一併刪除上列 App 資料」——再重新安裝。",
package/dist/cli.js CHANGED
@@ -39,6 +39,8 @@ import { checkReproducible } from "./reproducible.js";
39
39
  import { spkApprovalDiff, formatApprovalDiff } from "./runtime/approval-diff.js";
40
40
  import { inspectAnySpk } from "./spk/inspect.js";
41
41
  import { terminalSafe } from "./terminal-text.js";
42
+ import { auditGoRoutes } from "./standalone/route-audit.js";
43
+ import { parseApiPermissions } from "./standalone/api-permissions.js";
42
44
  function failure(code, message, hint = "Fix the configuration and retry; use --help to see command usage.") {
43
45
  return Object.assign(new Error(codedMessage(code, message)), { code, hint });
44
46
  }
@@ -335,6 +337,30 @@ async function runCliWithin(argv) {
335
337
  .option("--json", "Print a single JSON object")
336
338
  .action(async (id, options) => { const { uninstallPackage } = await import('./host/package-commands.js'); const result = await uninstallPackage(id, options); emit('uninstall', result, JSON.stringify(result, null, 2)); });
337
339
  program.command("ctl").description("Control registered NAS Apps using described AI commands (ctl --help)");
340
+ program.command("audit-entry <main>")
341
+ .description("Audit one custom Go payload's target import graph without executing it")
342
+ .option("--dir <dir>", "Go module directory", ".")
343
+ .option("--arch <arches>", "Comma-separated Go target architectures", "amd64,arm64,arm")
344
+ .option("--permissions <file>", "App permissions JSON, when the payload registers App methods")
345
+ .option("--json", "Print a single JSON object")
346
+ .action((main, options) => {
347
+ const arches = options.arch.split(",");
348
+ if (!arches.length || arches.some(arch => !["amd64", "arm64", "arm"].includes(arch))) {
349
+ throw new Error("audit-entry: --arch must contain amd64, arm64 or arm");
350
+ }
351
+ const dir = resolve(options.dir);
352
+ const policy = options.permissions
353
+ ? parseApiPermissions(readFileSync(resolve(dir, options.permissions)))
354
+ : { schema: 1, default: "administrators", methods: {} };
355
+ const audit = auditGoRoutes(dir, policy, {}, [main], arches);
356
+ const ok = audit.problems.length === 0;
357
+ if (json)
358
+ stdout(JSON.stringify({ ok, ...audit }));
359
+ else
360
+ stdout(terminalSafe(ok ? "Entry audit passed" : audit.problems.join("\n")));
361
+ if (!ok)
362
+ process.exitCode = 1;
363
+ });
338
364
  program
339
365
  .command("check")
340
366
  .description("Validate the manifest, capability combination and Compose")
@@ -843,6 +843,16 @@ export const ERROR_REGISTRY = {
843
843
  description: "The API policy is invalid.",
844
844
  suggestion: "Fix backend/internal/api/permissions.json.",
845
845
  },
846
+ STANDALONE_INTEGRATIONS_INVALID: {
847
+ code: "STANDALONE_INTEGRATIONS_INVALID",
848
+ description: "The integrations list in dsm-package.yaml is invalid or used where an integration callback cannot run.",
849
+ suggestion: "List each integration method once, sorted, as a string; integrations need a Go backend without compose.",
850
+ },
851
+ STANDALONE_INTEGRATIONS_MISMATCH: {
852
+ code: "STANDALONE_INTEGRATIONS_MISMATCH",
853
+ description: "The integrations list does not match the methods permissions.json marks integration.",
854
+ suggestion: "List exactly the integration methods from permissions.json in dsm-package.yaml integrations.",
855
+ },
846
856
  CLI_OPTION_UNKNOWN: {
847
857
  code: "CLI_OPTION_UNKNOWN",
848
858
  description: "Unknown CLI option.",
@@ -5,7 +5,8 @@ export declare const API_PERMISSIONS_FILE: Record<SdkBackend["kind"], string>;
5
5
  type SdkPermissions = Record<string, Record<string, ApiPermission>>;
6
6
  export declare const MAX_PERMISSIONS_BYTES = 65536;
7
7
  export declare const MAX_PERMISSION_METHODS = 1024;
8
- export type ApiPermission = "administrators" | "authenticated" | "public";
8
+ /** `integration` marks a Go `api.HandleIntegration` callback that arrives without a DSM session. */
9
+ export type ApiPermission = "administrators" | "authenticated" | "public" | "integration";
9
10
  export interface ApiPermissions {
10
11
  schema: 1;
11
12
  default: "administrators";
@@ -30,4 +31,6 @@ export declare function floorAtAdministrators(policy: ApiPermissions, sdkPermiss
30
31
  };
31
32
  /** Launcher access is derived from the same API policy; no second setting. */
32
33
  export declare function permitsOrdinaryUsers(policy?: ApiPermissions): boolean;
34
+ /** Methods declared with the `integration` permission, sorted. */
35
+ export declare function integrationMethods(policy?: ApiPermissions): string[];
33
36
  export {};
@@ -27,7 +27,7 @@ export function parseApiPermissions(raw) {
27
27
  throw new Error("too many permission methods");
28
28
  for (const [method, permission] of entries) {
29
29
  const matched = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.exec(method);
30
- if (method.length > 128 || matched?.[0] !== method || !["administrators", "authenticated", "public"].includes(permission)) {
30
+ if (method.length > 128 || matched?.[0] !== method || !["administrators", "authenticated", "public", "integration"].includes(permission)) {
31
31
  throw new Error(`invalid permission for method ${JSON.stringify(method)}`);
32
32
  }
33
33
  }
@@ -63,11 +63,17 @@ export function readApiPermissions(backend, capabilities = {}) {
63
63
  export function floorAtAdministrators(policy, sdkPermissions) {
64
64
  const sdk = new Set(Object.values(sdkPermissions).flatMap(methods => Object.keys(methods)));
65
65
  const methods = Object.fromEntries(Object.entries(policy.methods)
66
- .map(([method, permission]) => [method, sdk.has(method) && permission === "public" ? permission : "administrators"]));
67
- const widened = Object.entries(policy.methods).filter(([method, permission]) => !sdk.has(method) && permission !== "administrators").map(([method]) => method);
66
+ .map(([method, permission]) => [method, sdk.has(method) && permission === "public" || permission === "integration" ? permission : "administrators"]));
67
+ // An integration callback admits no DSM account by itself: api.ActAs refuses
68
+ // every account in an administrator-only App.
69
+ const widened = Object.entries(policy.methods).filter(([method, permission]) => !sdk.has(method) && permission !== "administrators" && permission !== "integration").map(([method]) => method);
68
70
  return { policy: { ...policy, methods }, widened };
69
71
  }
70
72
  /** Launcher access is derived from the same API policy; no second setting. */
71
73
  export function permitsOrdinaryUsers(policy) {
72
- return policy !== undefined && Object.values(policy.methods).some(permission => permission !== "administrators");
74
+ return policy !== undefined && Object.values(policy.methods).some(permission => permission !== "administrators" && permission !== "integration");
75
+ }
76
+ /** Methods declared with the `integration` permission, sorted. */
77
+ export function integrationMethods(policy) {
78
+ return Object.entries(policy?.methods ?? {}).filter(([, permission]) => permission === "integration").map(([method]) => method).sort();
73
79
  }
@@ -254,6 +254,7 @@ async function buildGoCgi(manifest, backend, arch, work, run) {
254
254
  `-X ${SDK_MODULE}/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
255
255
  `-X ${SDK_MODULE}/api.expectedHTTPRoutesDigest=${httpRoutesDigest(audit)}`,
256
256
  `-X ${SDK_MODULE}/ipc.packageID=${manifest.package}`,
257
+ `-X ${SDK_MODULE}/ipc.executorTier=none`,
257
258
  ...(manifest.entry ? [`-X ${SDK_MODULE}/api.expectedEntryMode=${manifest.entry}`] : []),
258
259
  ...delegateBuildFlags(manifest),
259
260
  ...(manifest.executor
@@ -314,6 +315,7 @@ async function buildBackgroundWorker(manifest, arch, work, run) {
314
315
  "-buildid=",
315
316
  `-X main.packageID=${manifest.package}`,
316
317
  `-X ${SDK_MODULE}/ipc.packageID=${manifest.package}`,
318
+ `-X ${SDK_MODULE}/ipc.executorTier=none`,
317
319
  `-X ${SDK_MODULE}/api.expectedHTTPRoutesDigest=${httpRoutesDigest(audit)}`,
318
320
  `-X ${SDK_MODULE}/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
319
321
  `-X ${SDK_MODULE}/api.expectedSDKPermissionsDigest=${manifest.apiSDKPermissionsDigest}`,
@@ -50,6 +50,8 @@ export interface SpkInspection {
50
50
  };
51
51
  /** Declared `entry` mode; `admin-only` is the administrator floor of every route. */
52
52
  entry?: EntryMode;
53
+ /** Service callbacks that skip the DSM session, as listed in the install and upgrade wizards. */
54
+ integrations?: string[];
53
55
  /** The declared launch mode verified against the built launcher. */
54
56
  launch?: WebLaunchMode;
55
57
  /** The App's own scripts/app/<DSM script name>, each called from the generated script of that name. */
@@ -31,8 +31,8 @@ import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
31
31
  import { DSM_LANGS } from "./i18n.js";
32
32
  import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, BRIDGE_WORKER_ARGS, backgroundUnitName, renderBackgroundUnit, } from "./background.js";
33
33
  import { renderStandaloneResource } from "./resource.js";
34
- import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER, } from "./shared-folder.js";
35
- import { renderInstallWizardFiles, renderNativeInstallWizard, } from "../build-spk/install-wizard.js";
34
+ import { recoverSharedFolder, renderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER, } from "./shared-folder.js";
35
+ import { renderInstallWizardFiles, integrationWizardSteps, renderNativeInstallWizard, } from "../build-spk/install-wizard.js";
36
36
  import { renderDelegateScopeSteps, } from "../build-spk/delegate-scope.js";
37
37
  import { renderUninstallWizardFiles } from "../build-spk/uninstall-wizard.js";
38
38
  import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
@@ -125,6 +125,8 @@ export async function inspectSpk(spkPath, expectedKind) {
125
125
  throw new Error();
126
126
  if (raw.operationSeconds !== undefined && (!Number.isInteger(raw.operationSeconds) || raw.operationSeconds < 1 || raw.operationSeconds > 3600))
127
127
  throw new Error();
128
+ if (raw.integrations !== undefined && (raw.backend !== 'go' || !Array.isArray(raw.integrations) || !raw.integrations.length || raw.integrations.length > 1024 || raw.integrations.some((m, i) => typeof m !== 'string' || m.length > 128 || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(m) || i > 0 && raw.integrations[i - 1] >= m)))
129
+ throw new Error();
128
130
  metadata = raw;
129
131
  }
130
132
  catch {
@@ -138,6 +140,7 @@ export async function inspectSpk(spkPath, expectedKind) {
138
140
  const identityGateway = await inspectProviderOnlyAdmission(dir, paths, metadata.provider_only_oidc, findings);
139
141
  await inspectExternalGateway(dir, paths, payload, metadata.gateway_external, findings);
140
142
  const installSettings = await inspectInstallSettings(dir, payload, metadata, findings);
143
+ const integrations = metadata.integrations ?? [];
141
144
  if (metadata.compose !== undefined) {
142
145
  const result = await inspectUnifiedCompose(dir, payload, { ...metadata, installSettings }, findings);
143
146
  if (installSettings)
@@ -291,7 +294,7 @@ export async function inspectSpk(spkPath, expectedKind) {
291
294
  ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings)
292
295
  : undefined;
293
296
  if (standalone)
294
- checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === "install-selected", portal, hasExecutor, nativeGateway, info.os_min_ver, installSettings);
297
+ checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === "install-selected", portal, hasExecutor, nativeGateway, info.os_min_ver, installSettings, integrations);
295
298
  const icon = inspectPackageIcons(dir, findings);
296
299
  const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
297
300
  if (standalone && !cgiEntry) {
@@ -319,6 +322,7 @@ export async function inspectSpk(spkPath, expectedKind) {
319
322
  }
320
323
  : {}),
321
324
  ...(metadata.entry ? { entry: metadata.entry } : {}),
325
+ ...(integrations.length ? { integrations } : {}),
322
326
  ...(appScripts.length ? { appScripts } : {}),
323
327
  ...(loginStyle ? { loginStyle } : {}),
324
328
  ...(standalone
@@ -1365,7 +1369,7 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
1365
1369
  * declaration (without one, any present file must be the default native
1366
1370
  * install wizard), and no other install/upgrade wizard file may exist.
1367
1371
  */
1368
- function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false, nativeGateway, osMinVer, installSettings) {
1372
+ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false, nativeGateway, osMinVer, installSettings, integrations = []) {
1369
1373
  const wizardDir = join(dir, "WIZARD_UIFILES");
1370
1374
  const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : [])
1371
1375
  .filter((name) => /^(install|upgrade)_uifile(_|\.|$)/.test(name))
@@ -1391,16 +1395,18 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal,
1391
1395
  : {}),
1392
1396
  ...portalManifest(portal),
1393
1397
  };
1394
- const wizards = expectedWizardFiles(pkg, folder, selected, portal, installSettings);
1398
+ const wizards = expectedWizardFiles(pkg, folder, selected, portal, installSettings, integrations);
1395
1399
  const source = selected
1396
1400
  ? "the install-selected policy"
1397
1401
  : folder
1398
1402
  ? "the install.sharedFolder declaration"
1399
1403
  : portal
1400
1404
  ? "the portal declaration"
1401
- : "the native-go default install wizard";
1405
+ : integrations.length
1406
+ ? "the integrations declaration"
1407
+ : "the native-go default install wizard";
1402
1408
  // The default native wizard is optional; a declared shared folder or portal needs all of its files.
1403
- for (const name of folder || selected || portal || installSettings
1409
+ for (const name of folder || selected || portal || installSettings || integrations.length
1404
1410
  ? Object.keys(wizards)
1405
1411
  : present.filter((n) => n in wizards)) {
1406
1412
  if (readOptional(join(wizardDir, name)) !== wizards[name])
@@ -1461,7 +1467,7 @@ async function inspectInstallSettings(dir, payload, metadata, findings) {
1461
1467
  }
1462
1468
  return settings;
1463
1469
  }
1464
- function expectedWizardFiles(pkg, folder, selected = false, portal, installSettings) {
1470
+ function expectedWizardFiles(pkg, folder, selected = false, portal, installSettings, integrations = []) {
1465
1471
  const steps = selected
1466
1472
  ? (t) => [
1467
1473
  ...renderDelegateScopeSteps(t),
@@ -1470,11 +1476,11 @@ function expectedWizardFiles(pkg, folder, selected = false, portal, installSetti
1470
1476
  : folder
1471
1477
  ? sharedFolderInstallSteps(pkg, folder)
1472
1478
  : renderNativeInstallWizard;
1473
- const install = renderInstallWizardFiles(pkg, t => [...steps(t), ...settingsInstallSteps(installSettings)(t), ...(portal ? [renderPortalWizardStep(t, portal.alias)] : [])]);
1479
+ const install = renderInstallWizardFiles(pkg, t => [...steps(t), ...settingsInstallSteps(installSettings)(t), ...(portal ? [renderPortalWizardStep(t, portal.alias)] : []), ...integrationWizardSteps(t, integrations)]);
1474
1480
  return {
1475
1481
  ...install.static,
1476
1482
  ...install.dynamic,
1477
- ...(folder ? renderSharedFolderUpgradeWizardFiles(pkg, folder) : {}),
1483
+ ...renderUpgradeWizardFiles(pkg, folder, integrations),
1478
1484
  };
1479
1485
  }
1480
1486
  function portalManifest(portal) {
@@ -98,6 +98,12 @@ export interface StandaloneManifest {
98
98
  installSettings?: InstallSettings;
99
99
  /** Who may use the app at all; absent keeps the route policy alone. */
100
100
  entry?: EntryMode;
101
+ /**
102
+ * `integrations`: Go `api.HandleIntegration` methods that arrive without a
103
+ * DSM session, sorted. Exactly the `integration` permissions; the install
104
+ * and upgrade wizards list them for the administrator.
105
+ */
106
+ integrations?: string[];
101
107
  /** `portal`: registers the DSM Application Portal entry; an administrator activates it. */
102
108
  portal?: PortalDeclaration;
103
109
  /**
@@ -21,7 +21,7 @@ import { readAppScripts } from "./app-scripts.js";
21
21
  import { readBackground } from "./background.js";
22
22
  import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, portalByExecutor, portalMinDsmMessage, } from "../build-spk/dsm-version.js";
23
23
  import { readSharedFolder } from "./shared-folder.js";
24
- import { API_PERMISSIONS_FILE, floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions, } from "./api-permissions.js";
24
+ import { API_PERMISSIONS_FILE, floorAtAdministrators, integrationMethods, permitsOrdinaryUsers, readApiPermissions, } from "./api-permissions.js";
25
25
  import { PORTAL_REQUIRES_EXECUTOR, portalEntryProblem, readPortalDeclaration, } from "../portal/alias.js";
26
26
  import { renderPackageIcons } from "../build-spk/dsm-icons.js";
27
27
  import { LOGIN_COLOR, loginImageProblem, } from "../build-spk/login-style.js";
@@ -59,6 +59,7 @@ export const KNOWN_FIELDS = new Set([
59
59
  "install",
60
60
  "uninstall",
61
61
  "entry",
62
+ "integrations",
62
63
  "portal",
63
64
  "console",
64
65
  "icon",
@@ -223,9 +224,11 @@ function readManifest(raw, c) {
223
224
  c.fail("STANDALONE_REQUEST_TIMEOUT_SHORT", problem);
224
225
  }
225
226
  let permissions;
227
+ let integrations;
226
228
  if (backend && backend.kind !== "binary") {
227
229
  try {
228
230
  permissions = readApiPermissions(backend, capabilities);
231
+ integrations = readIntegrations(raw.integrations, backend, permissions.policy, raw.compose !== undefined, c);
229
232
  }
230
233
  catch (error) {
231
234
  // The SDK's own routes come from its module; a failed download or an
@@ -508,6 +511,7 @@ function readManifest(raw, c) {
508
511
  ...(sharedFolder ? { sharedFolder } : {}),
509
512
  ...(installSettings ? { installSettings } : {}),
510
513
  ...(entry ? { entry } : {}),
514
+ ...(integrations?.length ? { integrations } : {}),
511
515
  ...(portal ? { portal } : {}),
512
516
  ...(dsmConsole ? { console: dsmConsole } : {}),
513
517
  ...(noWindow ? { sdkWindow: false } : {}),
@@ -667,6 +671,29 @@ function sdkVersionOrUndefined(backend) {
667
671
  * SDK gate and main wires it; `-X main.entry` on an older main would be
668
672
  * silently dropped and leave a registered app ungated.
669
673
  */
674
+ /**
675
+ * `integrations` repeats, in dsm-package.yaml, every permissions.json method
676
+ * declared `integration`, so a callback that skips the DSM session is never
677
+ * added by the policy file alone. Only Go SDK servers implement it.
678
+ */
679
+ function readIntegrations(value, backend, policy, compose, c) {
680
+ const declared = integrationMethods(policy);
681
+ if (value === undefined && declared.length === 0)
682
+ return undefined;
683
+ if (value === undefined)
684
+ value = [];
685
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string") || new Set(value).size !== value.length) {
686
+ return c.fail("STANDALONE_INTEGRATIONS_INVALID", "integrations must be a list of unique permissions.json method names.");
687
+ }
688
+ const listed = [...value].sort();
689
+ if (backend.kind !== "go" || compose) {
690
+ return c.fail("STANDALONE_INTEGRATIONS_INVALID", "integrations need a Go backend built with api.HandleIntegration.");
691
+ }
692
+ if (listed.join("\n") !== declared.join("\n")) {
693
+ return c.fail("STANDALONE_INTEGRATIONS_MISMATCH", `integrations must list exactly the permissions.json methods declared "integration" (${declared.join(", ") || "none"}); found ${listed.join(", ") || "none"}.`);
694
+ }
695
+ return listed;
696
+ }
670
697
  function readEntry(value, backend, policy, c) {
671
698
  if (value !== "admin-only" && value !== "app-privilege") {
672
699
  return c.fail("STANDALONE_ENTRY_INVALID", "entry must be admin-only or app-privilege; omit it to keep the existing permissions.json behavior.");
@@ -1,6 +1,6 @@
1
1
  import type { StandaloneError } from "./manifest.js";
2
2
  import { type Translate } from "../build-spk/wizard-i18n.js";
3
- import type { InstallWizardStep } from "../build-spk/install-wizard.js";
3
+ import { type InstallWizardStep } from "../build-spk/install-wizard.js";
4
4
  /**
5
5
  * `install.sharedFolder`: the Shared Folder a package keeps its files in.
6
6
  *
@@ -33,6 +33,11 @@ export declare function renderSharedFolderWizardFiles(pkg: string, folder: Share
33
33
  export declare function sharedFolderInstallSteps(pkg: string, folder: SharedFolder): (t: Translate) => InstallWizardStep[];
34
34
  /** The upgrade wizard files that let the administrator keep or change the shared folder. */
35
35
  export declare function renderSharedFolderUpgradeWizardFiles(pkg: string, folder: SharedFolder): Record<string, string>;
36
+ /**
37
+ * The upgrade wizard: the shared folder choice, then the service callbacks an
38
+ * administrator re-approves on every upgrade. Empty without either.
39
+ */
40
+ export declare function renderUpgradeWizardFiles(pkg: string, folder: SharedFolder | undefined, integrations?: readonly string[]): Record<string, string>;
36
41
  /** `conf/resource` `data-share` entry: create mode only; DSM never deletes the share on uninstall. */
37
42
  export declare function sharedFolderResource(pkg: string, folder: SharedFolder): Record<string, unknown> | undefined;
38
43
  /** The declaration an SPK's postinst carries, or undefined when it has none or it is no longer valid. */
@@ -1,5 +1,6 @@
1
1
  import { sharedFolderCombobox } from "../build-spk/share-combobox.js";
2
2
  import { renderWizardFiles } from "../build-spk/wizard-i18n.js";
3
+ import { integrationWizardSteps } from "../build-spk/install-wizard.js";
3
4
  const FIELDS = new Set(["mode", "key", "name", "permission"]);
4
5
  const KEY = /^[a-z][a-z0-9_]{0,31}$/;
5
6
  const RESERVED_KEY = /^(pkgwizard_|synopkg|change_)/;
@@ -123,6 +124,18 @@ export function sharedFolderInstallSteps(pkg, folder) {
123
124
  export function renderSharedFolderUpgradeWizardFiles(pkg, folder) {
124
125
  return renderWizardFiles("upgrade_uifile", t => [{ step_title: t("upgrade.step"), items: upgradeItems(t, pkg, folder) }]);
125
126
  }
127
+ /**
128
+ * The upgrade wizard: the shared folder choice, then the service callbacks an
129
+ * administrator re-approves on every upgrade. Empty without either.
130
+ */
131
+ export function renderUpgradeWizardFiles(pkg, folder, integrations = []) {
132
+ if (!folder && !integrations.length)
133
+ return {};
134
+ return renderWizardFiles("upgrade_uifile", t => [
135
+ ...(folder ? [{ step_title: t("upgrade.step"), items: upgradeItems(t, pkg, folder) }] : []),
136
+ ...integrationWizardSteps(t, integrations),
137
+ ]);
138
+ }
126
139
  /** `conf/resource` `data-share` entry: create mode only; DSM never deletes the share on uninstall. */
127
140
  export function sharedFolderResource(pkg, folder) {
128
141
  if (folder.mode !== "create")
@@ -13,7 +13,7 @@ import { infoArchValue } from "../build-spk/dsm-arch.js";
13
13
  import { packageIcons, writeDsmWindowIcons, writePackageCenterIcons, } from "../build-spk/dsm-icons.js";
14
14
  import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
15
15
  import { renderUninstallWizardFiles } from "../build-spk/uninstall-wizard.js";
16
- import { renderInstallWizardFiles, renderNativeInstallWizard, } from "../build-spk/install-wizard.js";
16
+ import { renderInstallWizardFiles, integrationWizardSteps, renderNativeInstallWizard, } from "../build-spk/install-wizard.js";
17
17
  import { writeWizardUiFiles, } from "../build-spk/wizard-i18n.js";
18
18
  import { renderDelegateScopeSteps } from "../build-spk/delegate-scope.js";
19
19
  import { createReproducibleTar } from "../build-spk/reproducible-archive.js";
@@ -40,7 +40,7 @@ import { portalByExecutor } from "../build-spk/dsm-version.js";
40
40
  import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalMustache, renderPortalWizardStep, } from "../portal/package.js";
41
41
  import { executorPlan, } from "../runtime/executor-build.js";
42
42
  import { EXECUTOR_ANCHOR } from "../runtime/executor-inspect.js";
43
- import { renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, } from "./shared-folder.js";
43
+ import { renderUpgradeWizardFiles, sharedFolderInstallSteps, } from "./shared-folder.js";
44
44
  import { stateItemTexts, uninstallExecProblem, uninstallRecord, } from "./uninstall.js";
45
45
  const FILE_MODE = 0o644;
46
46
  const EXEC_MODE = 0o755;
@@ -237,6 +237,7 @@ export async function assembleStandaloneSpk(options) {
237
237
  schema: 2,
238
238
  ...(moduleVersion ? { ui_module: moduleVersion } : {}),
239
239
  ...(manifest.installSettings ? { installSettings: manifest.installSettings } : {}),
240
+ ...(manifest.integrations ? { integrations: manifest.integrations } : {}),
240
241
  ...(manifest.lifecycleTimeouts ? { lifecycleTimeouts: manifest.lifecycleTimeouts } : {}),
241
242
  ...("timeoutSeconds" in manifest.backend && manifest.backend.timeoutSeconds ? { operationSeconds: manifest.backend.timeoutSeconds } : {}),
242
243
  ...(options.apiDescription ? { ctl: { hash: options.apiDescription.sha256, registry: options.apiRegistry } } : {}),
@@ -295,13 +296,11 @@ export async function assembleStandaloneSpk(options) {
295
296
  : folder
296
297
  ? sharedFolderInstallSteps(manifest.package, folder)
297
298
  : renderNativeInstallWizard;
298
- const install = renderInstallWizardFiles(manifest.package, (t) => [...steps(t), ...settingsInstallSteps(manifest.installSettings)(t), ...(portal ? [renderPortalWizardStep(t, portal.alias)] : [])]);
299
+ const install = renderInstallWizardFiles(manifest.package, (t) => [...steps(t), ...settingsInstallSteps(manifest.installSettings)(t), ...(portal ? [renderPortalWizardStep(t, portal.alias)] : []), ...integrationWizardSteps(t, manifest.integrations)]);
299
300
  writeWizardUiFiles(staging, {
300
301
  ...install.static,
301
302
  ...install.dynamic,
302
- ...(folder
303
- ? renderSharedFolderUpgradeWizardFiles(manifest.package, folder)
304
- : {}),
303
+ ...renderUpgradeWizardFiles(manifest.package, folder, manifest.integrations),
305
304
  ...renderUninstallWizardFiles(manifest.package, "native", {
306
305
  composeData: manifest.compose?.plan.app_data,
307
306
  executor: Boolean(options.executor),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
5
5
 
6
6
  # Independent Synology SPKs
7
7
 
8
- CLI 0.35.0 installs templates through `~0.32.0`. A templates patch needs only a
8
+ CLI 0.36.0 installs templates through `~0.32.0`. A templates patch needs only a
9
9
  templates release; npm install/update selects a patch in that minor line. The CLI
10
10
  uses the locally installed templates and reads their SDK pin for defaults; it
11
11
  never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
@@ -24,7 +24,7 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
24
24
 
25
25
  For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
26
26
 
27
- Use SDK 0.33.0 and CLI 0.35.0 for App entry enforcement. Declare every App method
27
+ Use SDK/App 0.34.0 and CLI 0.36.0 for App entry enforcement. Declare every App method
28
28
  in the permissions table; dynamic names and unregistered HTTP dispatchers are rejected.
29
29
  Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
30
30
  paths that rely on upstream authentication. Read
@@ -41,6 +41,36 @@ cannot upgrade a connection; use an existing upgrade-capable deployment
41
41
  transport. The audit follows each configured Go binary's Linux import graph,
42
42
  including declared workers; unshipped dev commands are excluded.
43
43
 
44
+ Use `ipc.StartUnix(ctx, path, handler)` for multiple private data-plane sockets.
45
+ Each direct `.sock` child of the package `var/run` gets an independent handle
46
+ with Close, Shutdown and Wait. Stop/revoke closes active streams and hijacked
47
+ connections, including WebSocket; keep target approval and pin checks in the
48
+ handler. A private socket may serve the upstream root path without CGI.
49
+ For official supervised children use `ipc.ReserveLoopback(ctx, 0)` to hold a
50
+ 127.0.0.1 TCP port, read Port/Address and Release immediately before starting
51
+ the child. `ipc.ProbeLoopback(ctx, port)` checks a specific port without
52
+ retaining it. Check child readiness and retry bind conflicts: release-to-bind
53
+ is inherently racy. Never replace these with custom listeners or fixed ports.
54
+
55
+ An optional root helper is a separately approved executor payload.
56
+ `ipc.ServePeerUnix(ctx, path, ipc.PeerPolicy{UID: uid, GID: gid}, handler)`
57
+ supports a bounded raw JSON control protocol on a root-owned 0660 socket under
58
+ `/var/run/synopkgland/<package>/`. The SDK authenticates exactly the selected
59
+ UID with SO_PEERCRED; the handler validates bounded frames and current
60
+ authorization. `ipc.DialPeerUnix` verifies root ownership, protected ancestors,
61
+ mode and server UID zero. The server requires root AND the helper's explicit
62
+ `ipc.executorTier=executor` linker binding. Bind packageID in custom builds.
63
+ Generated CGI/workers bind tier `none`; never launch or install them as root.
64
+ The transport does not provide installation, approval or privilege elevation.
65
+
66
+ Audit every custom-hook Go payload with
67
+ `synopkgland audit-entry ./cmd/helper --dir backend --arch amd64,arm64,arm --json`
68
+ before packaging. Use `--permissions` when it registers App methods.
69
+ Each entry is checked against its actual target import graph, including every
70
+ file of shared packages. Unused server functions have no exemption. Use real
71
+ client/server packages or supported SDK boundaries; no App allowlists, hidden
72
+ directories, aliases or ABI workarounds.
73
+
44
74
  **If you package someone else's open-source project, set `maintainer` to the
45
75
  original project/owner (prefer the project's own team/org name, else the owner's
46
76
  display name, else login), `metadata.maintainer_url` to the upstream repository,