@synopackageland/templates 0.9.9 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +29 -29
  2. package/package.json +2 -2
  3. package/templates/_shared/AGENTS.md +53 -15
  4. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
  5. package/templates/native-go/backend/cmd/app/main.go +15 -1
  6. package/templates/native-go/backend/cmd/app/main_test.go +33 -0
  7. package/templates/native-go/backend/go.mod +1 -1
  8. package/templates/native-go/backend/go.sum +2 -2
  9. package/templates/native-go/i18n/messages/de.json +4 -0
  10. package/templates/native-go/i18n/messages/en.json +4 -0
  11. package/templates/native-go/i18n/messages/es.json +4 -0
  12. package/templates/native-go/i18n/messages/fr.json +4 -0
  13. package/templates/native-go/i18n/messages/it.json +4 -0
  14. package/templates/native-go/i18n/messages/ja.json +4 -0
  15. package/templates/native-go/i18n/messages/ko.json +4 -0
  16. package/templates/native-go/i18n/messages/zh-CN.json +4 -0
  17. package/templates/native-go/i18n/messages/zh-TW.json +4 -0
  18. package/templates/native-go/ui/app.js +2 -1
  19. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +24 -3
  20. package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
  21. package/templates/native-go-root-ops/backend/internal/app/executor.go +2 -65
  22. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +10 -82
  23. package/templates/native-go-root-ops/frontend/src/ExecutorGate.vue +16 -0
  24. package/templates/native-go-root-ops/frontend/test/executor-gate.test.ts +60 -0
  25. package/templates/native-go-vue/README.md +24 -8
  26. package/templates/native-go-vue/frontend/package-lock.json +9 -9
  27. package/templates/native-go-vue/frontend/package.json +2 -2
  28. package/templates/native-go-vue/frontend/src/App.vue +36 -36
  29. package/templates/native-go-vue/frontend/src/ExecutorGate.vue +10 -0
  30. package/templates/native-go-vue/frontend/test/apps.test.ts +65 -12
  31. package/templates/native-go-vue/frontend/test/context.ts +8 -0
  32. package/templates/native-go-vue/frontend/test/native-controls.test.ts +3 -4
  33. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +2 -2
  34. package/templates/native-node/README.md +153 -0
  35. package/templates/native-node/backend/app/package.json +4 -0
  36. package/templates/native-node/backend/app/permissions.json +5 -0
  37. package/templates/native-node/backend/package-lock.json +59 -0
  38. package/templates/native-node/backend/package.json +17 -0
  39. package/templates/native-node/backend/src/delegate.ts +5 -0
  40. package/templates/native-node/backend/src/jobs.ts +5 -0
  41. package/templates/native-node/backend/src/main.ts +4 -0
  42. package/templates/native-node/backend/src/params.ts +11 -0
  43. package/templates/native-node/backend/src/root-ops.ts +5 -0
  44. package/templates/native-node/backend/src/routes.ts +97 -0
  45. package/templates/native-node/backend/src/service.ts +22 -0
  46. package/templates/native-node/backend/test/routes.test.ts +75 -0
  47. package/templates/native-node/backend/test/support.ts +67 -0
  48. package/templates/native-node/backend/tsconfig.build.json +8 -0
  49. package/templates/native-node/backend/tsconfig.json +18 -0
  50. package/templates/native-node/dsm-package.yaml +80 -0
  51. package/templates/native-node/gitignore +4 -0
  52. package/templates/native-node-delegate/backend/src/delegate.ts +35 -0
  53. package/templates/native-node-delegate/backend/test/delegate.test.ts +51 -0
  54. package/templates/native-node-jobs/backend/src/jobs.ts +38 -0
  55. package/templates/native-node-jobs/backend/test/jobs.test.ts +73 -0
  56. package/templates/native-node-root-ops/backend/src/root-ops.ts +16 -0
  57. package/templates/native-node-root-ops/backend/test/root-ops.test.ts +23 -0
  58. package/templates/native-node-vue/README.md +56 -0
  59. package/templates/native-node-vue/gitignore +4 -0
  60. package/templates/native-python/README.md +150 -0
  61. package/templates/native-python/backend/app/delegate.py +5 -0
  62. package/templates/native-python/backend/app/jobs.py +5 -0
  63. package/templates/native-python/backend/app/main.py +5 -0
  64. package/templates/native-python/backend/app/params.py +10 -0
  65. package/templates/native-python/backend/app/permissions.json +5 -0
  66. package/templates/native-python/backend/app/root_ops.py +5 -0
  67. package/templates/native-python/backend/app/routes.py +107 -0
  68. package/templates/native-python/backend/tests/support.py +72 -0
  69. package/templates/native-python/backend/tests/test_routes.py +73 -0
  70. package/templates/native-python/dsm-package.yaml +77 -0
  71. package/templates/native-python/gitignore +2 -0
  72. package/templates/native-python-delegate/backend/app/delegate.py +42 -0
  73. package/templates/native-python-delegate/backend/tests/test_delegate.py +58 -0
  74. package/templates/native-python-jobs/backend/app/jobs.py +40 -0
  75. package/templates/native-python-jobs/backend/tests/test_jobs.py +72 -0
  76. package/templates/native-python-root-ops/backend/app/root_ops.py +18 -0
  77. package/templates/native-python-root-ops/backend/tests/test_root_ops.py +34 -0
  78. package/templates/native-python-vue/README.md +56 -0
  79. package/templates/native-python-vue/gitignore +3 -0
  80. package/templates/native-rust/README.md +74 -296
  81. package/templates/native-rust/backend/Cargo.lock +6 -6
  82. package/templates/native-rust/backend/Cargo.toml +2 -10
  83. package/templates/native-rust/backend/src/delegate.rs +6 -0
  84. package/templates/native-rust/backend/src/jobs.rs +6 -0
  85. package/templates/native-rust/backend/src/main.rs +12 -0
  86. package/templates/native-rust/backend/src/root_ops.rs +6 -0
  87. package/templates/native-rust/backend/src/routes.rs +352 -0
  88. package/templates/native-rust/backend/src/test_support.rs +91 -0
  89. package/templates/native-rust/dsm-package.yaml +10 -21
  90. package/templates/native-rust-delegate/backend/src/delegate.rs +171 -0
  91. package/templates/native-rust-jobs/backend/src/jobs.rs +240 -0
  92. package/templates/native-rust-root-ops/backend/src/root_ops.rs +83 -0
  93. package/templates/native-rust-vue/README.md +35 -34
  94. package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +0 -207
  95. package/templates/native-rust/backend/src/app/background/mod.rs +0 -424
  96. package/templates/native-rust/backend/src/app/background/owner.rs +0 -77
  97. package/templates/native-rust/backend/src/app/background/owner_tests.rs +0 -174
  98. package/templates/native-rust/backend/src/app/background/schedule.rs +0 -126
  99. package/templates/native-rust/backend/src/app/background/schedule_tests.rs +0 -108
  100. package/templates/native-rust/backend/src/app/background/tests.rs +0 -505
  101. package/templates/native-rust/backend/src/app/background/work.rs +0 -78
  102. package/templates/native-rust/backend/src/app/background/work_tests.rs +0 -123
  103. package/templates/native-rust/backend/src/app/background_routes.rs +0 -22
  104. package/templates/native-rust/backend/src/app/delegate_config.rs +0 -188
  105. package/templates/native-rust/backend/src/app/delegate_routes.rs +0 -22
  106. package/templates/native-rust/backend/src/app/identity.rs +0 -56
  107. package/templates/native-rust/backend/src/app/migrations.rs +0 -79
  108. package/templates/native-rust/backend/src/app/mod.rs +0 -30
  109. package/templates/native-rust/backend/src/app/portal.rs +0 -317
  110. package/templates/native-rust/backend/src/app/root_routes.rs +0 -23
  111. package/templates/native-rust/backend/src/app/routes.rs +0 -315
  112. package/templates/native-rust/backend/src/app/session_files.rs +0 -19
  113. package/templates/native-rust/backend/src/app/shared_folder.rs +0 -383
  114. package/templates/native-rust/backend/src/app/test_support.rs +0 -202
  115. package/templates/native-rust/backend/src/app/uninstall.rs +0 -38
  116. package/templates/native-rust/backend/src/bin/api.rs +0 -72
  117. package/templates/native-rust/backend/src/bin/background-worker.rs +0 -37
  118. package/templates/native-rust/backend/src/build_env.rs +0 -155
  119. package/templates/native-rust/backend/src/lib.rs +0 -6
  120. package/templates/native-rust/backend/src/permissions.rs +0 -54
  121. package/templates/native-rust/backend/src/process.rs +0 -91
  122. package/templates/native-rust/i18n/glossary.json +0 -665
  123. package/templates/native-rust/i18n/messages/de.json +0 -32
  124. package/templates/native-rust/i18n/messages/en.json +0 -32
  125. package/templates/native-rust/i18n/messages/es.json +0 -32
  126. package/templates/native-rust/i18n/messages/fr.json +0 -32
  127. package/templates/native-rust/i18n/messages/it.json +0 -32
  128. package/templates/native-rust/i18n/messages/ja.json +0 -32
  129. package/templates/native-rust/i18n/messages/ko.json +0 -32
  130. package/templates/native-rust/i18n/messages/zh-CN.json +0 -32
  131. package/templates/native-rust/i18n/messages/zh-TW.json +0 -32
  132. package/templates/native-rust/ui/app.css +0 -45
  133. package/templates/native-rust/ui/app.js +0 -111
  134. package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +0 -92
  135. package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +0 -52
  136. package/templates/native-rust-jobs/backend/src/app/background_routes.rs +0 -109
  137. package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +0 -475
@@ -0,0 +1,35 @@
1
+ // Saves to a user's destination: write a report and move it there.
2
+ //
3
+ // The browser picks the destination with the SDK's Background saves page and
4
+ // sends its delegation ID; the executor checks that it is the caller's grant
5
+ // and writes the file as the caller, never as root. A save that failed may be
6
+ // repeated with the same job ID: the same file is sent again, and the executor
7
+ // copies it at most once.
8
+ import { mkdirSync, statSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { AppError, type App, type Context } from '@synopackageland/app'
11
+ import { strings } from './params.js'
12
+ import type { Config } from './routes.js'
13
+ import { service } from './service.js'
14
+
15
+ const JOB_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/
16
+
17
+ export function register(app: App, cfg: Config): void {
18
+ app.method('report.save', (ctx, params) => save(cfg, ctx, params))
19
+ }
20
+
21
+ function save(cfg: Config, ctx: Context, params: unknown) {
22
+ const { delegation_id, job_id } = strings(params, 'delegation_id', 'job_id')
23
+ if (!JOB_ID.test(job_id)) throw AppError.badRequest("job_id must be 1 to 128 letters, digits, '.', '_' or '-'")
24
+ const name = `report-${job_id}.txt`
25
+ // The runtime's delegate service moves files from here.
26
+ const staging = join(cfg.dataDir, 'executor-staging')
27
+ mkdirSync(staging, { recursive: true, mode: 0o700 })
28
+ const path = join(staging, name)
29
+ try {
30
+ writeFileSync(path, `Report for ${ctx.user}, ${new Date().toISOString().replace(/\.\d+Z$/, 'Z')}\n`, { flag: 'wx', mode: 0o600 })
31
+ } catch (e) {
32
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
33
+ }
34
+ return service(ctx, 'delegate.move_from_staging', { delegation_id, job_id, staging: name, name, size: statSync(path).size })
35
+ }
@@ -0,0 +1,51 @@
1
+ import assert from 'node:assert/strict'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterEach, beforeEach, test } from 'node:test'
6
+ import { build, type Config } from '../src/routes.js'
7
+ import { Refusal, call, type Services } from './support.js'
8
+
9
+ const SAVED = { state: 'queued' }
10
+ let cfg: Config
11
+ let staging: string
12
+
13
+ beforeEach(() => {
14
+ const root = mkdtempSync(join(tmpdir(), 'app-'))
15
+ cfg = { packageId: 'ExampleApp', dataDir: root, packagesRoot: root }
16
+ staging = join(root, 'executor-staging')
17
+ })
18
+ afterEach(() => rmSync(cfg.dataDir, { recursive: true, force: true }))
19
+
20
+ const save = (user = 'alice', move: Services[string] = () => SAVED) =>
21
+ call(build(cfg), 'report.save', { delegation_id: 'd1', job_id: 'job-1' }, { user, admin: false, services: { 'delegate.move_from_staging': move } })
22
+
23
+ test('report.save moves a private report to the caller\'s destination', async () => {
24
+ const reply = await save()
25
+ assert.deepEqual(reply.data, SAVED)
26
+ const path = join(staging, 'report-job-1.txt')
27
+ assert.match(readFileSync(path, 'utf8'), /alice/)
28
+ assert.equal(statSync(path).mode & 0o777, 0o600)
29
+ const move = { delegation_id: 'd1', job_id: 'job-1', staging: 'report-job-1.txt', name: 'report-job-1.txt', size: statSync(path).size }
30
+ assert.deepEqual(reply.services, [['delegate.move_from_staging', move]])
31
+ })
32
+
33
+ test('a repeated report.save sends the same file again', async () => {
34
+ const first = (await save()).services
35
+ const report = readFileSync(join(staging, 'report-job-1.txt'), 'utf8')
36
+ assert.deepEqual((await save('bob')).services, first)
37
+ assert.equal(readFileSync(join(staging, 'report-job-1.txt'), 'utf8'), report)
38
+ })
39
+
40
+ test('report.save passes on the executor\'s refusal', async () => {
41
+ const reply = await save('alice', () => { throw new Refusal('DELEGATION_DENIED', 'The destination is no longer allowed.') })
42
+ assert.equal(reply.error?.code, 'DELEGATION_DENIED')
43
+ })
44
+
45
+ test('report.save refuses invalid params before writing', async () => {
46
+ for (const params of [null, { delegation_id: 'd1' }, { delegation_id: 'd1', job_id: '../x' }, { delegation_id: 'd1', job_id: '' }, { delegation_id: 1, job_id: 'j' }]) {
47
+ const reply = await call(build(cfg), 'report.save', params)
48
+ assert.deepEqual([reply.error?.code, reply.services], ['bad_request', []], JSON.stringify(params))
49
+ }
50
+ assert.equal(existsSync(staging), false)
51
+ })
@@ -0,0 +1,38 @@
1
+ // Background jobs: hash a NAS file the caller may read.
2
+ //
3
+ // The SDK's worker runs each job as the package account: it hashes the job's
4
+ // input (SHA-256) and keeps the result for the job's owner, the caller. The
5
+ // runtime serves files.read with the caller's DSM session and bg.* for the
6
+ // caller's own jobs only.
7
+ import { copyFileSync, mkdirSync, unlinkSync } from 'node:fs'
8
+ import { randomUUID } from 'node:crypto'
9
+ import { basename, join } from 'node:path'
10
+ import { AppError, type App, type Context } from '@synopackageland/app'
11
+ import { strings } from './params.js'
12
+ import type { Config } from './routes.js'
13
+ import { service } from './service.js'
14
+
15
+ /** A job's input is at most 512 KiB, copied into the job when it is submitted. */
16
+ const MAX_INPUT = 512 * 1024
17
+
18
+ export function register(app: App, cfg: Config): void {
19
+ app.method('hash.submit', (ctx, params) => submit(cfg, ctx, params))
20
+ app.method('hash.get', (ctx, params) => service(ctx, 'bg.get', strings(params, 'id')))
21
+ app.method('hash.cancel', (ctx, params) => service(ctx, 'bg.cancel', strings(params, 'id')))
22
+ }
23
+
24
+ async function submit(cfg: Config, ctx: Context, params: unknown) {
25
+ const { key, path } = strings(params, 'key', 'path')
26
+ const read = await service<{ path: string; size: number }>(ctx, 'files.read', { path })
27
+ if (read.size > MAX_INPUT) throw new AppError('limit_exceeded', 'The file is larger than allowed.')
28
+ // bg.submit takes its input from the data directory; the call's spool is elsewhere.
29
+ const inputs = join(cfg.dataDir, 'hash-inputs')
30
+ mkdirSync(inputs, { recursive: true, mode: 0o700 })
31
+ const input = join(inputs, randomUUID())
32
+ copyFileSync(read.path, input)
33
+ try {
34
+ return await service(ctx, 'bg.submit', { key, name: basename(path), input })
35
+ } finally {
36
+ unlinkSync(input)
37
+ }
38
+ }
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict'
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, sep } from 'node:path'
5
+ import { afterEach, beforeEach, test } from 'node:test'
6
+ import { build, type Config } from '../src/routes.js'
7
+ import { Refusal, call } from './support.js'
8
+
9
+ const JOB = { job: { id: 'j1', state: 'queued' }, duplicate: false }
10
+ let root: string
11
+ let cfg: Config
12
+ let submitted: Record<string, unknown>[]
13
+
14
+ beforeEach(() => {
15
+ root = mkdtempSync(join(tmpdir(), 'app-'))
16
+ cfg = { packageId: 'ExampleApp', dataDir: join(root, 'var'), packagesRoot: join(root, 'packages') }
17
+ mkdirSync(cfg.dataDir)
18
+ submitted = []
19
+ })
20
+ afterEach(() => rmSync(root, { recursive: true, force: true }))
21
+
22
+ function spool(content: Buffer) {
23
+ const path = join(root, 'spool')
24
+ writeFileSync(path, content)
25
+ return { path, size: content.length }
26
+ }
27
+
28
+ // The runtime reads the input file while it answers bg.submit.
29
+ function submit(params: unknown) {
30
+ const p = params as { input: string }
31
+ submitted.push({ ...p, content: readFileSync(p.input, 'utf8') })
32
+ return JOB
33
+ }
34
+
35
+ test('hash.submit hashes a copy of the caller\'s file', async () => {
36
+ const services = { 'files.read': () => spool(Buffer.from('hello')), 'bg.submit': submit }
37
+ const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/report.txt' }, { services })
38
+ assert.deepEqual(reply.data, JOB)
39
+ assert.deepEqual(reply.services.map(([op]) => op), ['files.read', 'bg.submit'])
40
+ assert.deepEqual(reply.services[0]![1], { path: '/home/report.txt' })
41
+ const [job] = submitted as [{ key: string; name: string; content: string; input: string }]
42
+ assert.deepEqual([job.key, job.name, job.content], ['k1', 'report.txt', 'hello'])
43
+ assert.ok(job.input.startsWith(cfg.dataDir + sep), job.input)
44
+ assert.equal(existsSync(job.input), false, 'the copy is removed once submitted')
45
+ })
46
+
47
+ test('hash.submit refuses a file over the job input limit', async () => {
48
+ const services = { 'files.read': () => spool(Buffer.alloc(512 * 1024 + 1)), 'bg.submit': submit }
49
+ const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/big.bin' }, { services })
50
+ assert.equal(reply.error?.code, 'limit_exceeded')
51
+ assert.deepEqual(submitted, [])
52
+ })
53
+
54
+ test('hash.submit passes on the runtime\'s refusal', async () => {
55
+ const services = { 'files.read': () => { throw new Refusal('read_failed', 'Unable to read the file.') } }
56
+ const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/a' }, { services })
57
+ assert.equal(reply.error?.code, 'read_failed')
58
+ })
59
+
60
+ test('hash.submit refuses invalid params before any service', async () => {
61
+ for (const params of [null, { key: 'k1' }, { key: 1, path: '/home/a' }, { key: 'k1', path: '/home/a', x: 1 }]) {
62
+ const reply = await call(build(cfg), 'hash.submit', params)
63
+ assert.deepEqual([reply.error?.code, reply.services], ['bad_request', []], JSON.stringify(params))
64
+ }
65
+ })
66
+
67
+ test('hash.get and hash.cancel act on the caller\'s job', async () => {
68
+ for (const [method, op] of [['hash.get', 'bg.get'], ['hash.cancel', 'bg.cancel']] as const) {
69
+ const reply = await call(build(cfg), method, { id: 'j1' }, { services: { [op]: () => JOB.job } })
70
+ assert.deepEqual([reply.data, reply.services], [JOB.job, [[op, { id: 'j1' }]]])
71
+ assert.equal((await call(build(cfg), method, {})).error?.code, 'bad_request')
72
+ }
73
+ })
@@ -0,0 +1,16 @@
1
+ // Root operations: run root/whoami.sh as root.
2
+ //
3
+ // The executor runs only the root programs an administrator approved, and
4
+ // checks the caller's DSM session and Application Privileges itself; the App
5
+ // names the program and never runs anything as root.
6
+ import { AppError, type App } from '@synopackageland/app'
7
+ import type { Config } from './routes.js'
8
+ import { service } from './service.js'
9
+
10
+ export function register(app: App, _cfg: Config): void {
11
+ app.method('whoami.run', (ctx, params) => {
12
+ const none = params === null || (typeof params === 'object' && !Array.isArray(params) && Object.keys(params).length === 0)
13
+ if (!none) throw AppError.badRequest('whoami.run takes no params')
14
+ return service(ctx, 'root.run', { name: 'whoami' })
15
+ })
16
+ }
@@ -0,0 +1,23 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { build, type Config } from '../src/routes.js'
4
+ import { Refusal, call } from './support.js'
5
+
6
+ const cfg: Config = { packageId: 'ExampleApp', dataDir: '/nonexistent', packagesRoot: '/nonexistent' }
7
+
8
+ test('whoami.run runs the approved root program', async () => {
9
+ const output = { exit_code: 0, stdout: '0\n', stderr: '' }
10
+ for (const params of [null, {}]) {
11
+ const reply = await call(build(cfg), 'whoami.run', params, { services: { 'root.run': () => output } })
12
+ assert.deepEqual([reply.data, reply.services], [output, [['root.run', { name: 'whoami' }]]])
13
+ }
14
+ })
15
+
16
+ test('whoami.run passes on the executor\'s refusal', async () => {
17
+ const services = { 'root.run': () => { throw new Refusal('EXECUTOR_PAUSED', 'The executor is paused.') } }
18
+ assert.equal((await call(build(cfg), 'whoami.run', {}, { services })).error?.code, 'EXECUTOR_PAUSED')
19
+ })
20
+
21
+ test('whoami.run takes no params', async () => {
22
+ assert.equal((await call(build(cfg), 'whoami.run', { name: 'other' })).error?.code, 'bad_request')
23
+ })
@@ -0,0 +1,56 @@
1
+ # Native Node.js with Vue
2
+
3
+ The Vue shell is the same as the Go template's. Edit `frontend/src/App.vue`
4
+ and `main.ts` to build a DSM-window app with the public `@synopackageland/ui`
5
+ components and embedded theme. The shell, sign-in, files, portal, jobs and
6
+ delegate components come from `@synopackageland/sdk/vue` (types from
7
+ `@synopackageland/sdk`); its version must equal the `backend.sdk` in dsm-package.yaml. See
8
+ <https://github.com/synopackageland/syno-pkg-land-sdk> for the components and their props.
9
+
10
+ ```sh
11
+ cd frontend
12
+ npm ci
13
+ npm test
14
+ npm run build
15
+ ```
16
+
17
+ The base app reads package status and edits the package greeting through
18
+ `ctx.call`. Errors show the app's own `frontend/src/i18n` text, or the SDK's
19
+ `bridgeErrorCopy` for the codes the bridge runtime adds (`app_unavailable`
20
+ and the services' refusals). Each window has its own Vue mount, request state
21
+ and host subscriptions; unmount disposes subscriptions and ignores late responses.
22
+
23
+ The `--with jobs`, `root-ops` and `delegate` examples add their methods to
24
+ the Node.js App; the shell's panels call them with `ctx.call`. The SDK's
25
+ background-jobs page and executor settings call the runtime's own browser
26
+ methods (`bg.*`, `executor.*`); check the SDK release notes for the ones a
27
+ bridge App's runtime serves.
28
+
29
+ Use the installed `@synopackageland/ui`; package.json pins its version.
30
+ `App.vue` lays the shell out with the SDK's `AppShell` (`@synopackageland/sdk/vue`):
31
+ `pages` from `shellPages`, the header buttons in `#actions` and the pages in its
32
+ default slot. Given `ctx`, it follows the launch. In a DSM desktop window it
33
+ keeps `wide-layout="window"`, even maximized; the DSM host sets the 700px
34
+ minimum window width. A Portal or standalone launch owns the whole page
35
+ (`ctx.fullPage`, from the generated window): `AppShell` takes
36
+ `wide-layout="web"` (the SaaS layout at 1200px or wider, mobile on a phone) and
37
+ a device-width viewport, undone at unmount, since DSM's page has no
38
+ device-width viewport. Its full-page sidebar names the App with
39
+ `ctx.displayName` and `ctx.appIcon` (without them the page title and icon) and
40
+ shows the signed-in DSM account and its role from `ctx.user`. See the ui's
41
+ `docs/adaptive-layout.md`.
42
+ `AppShell`'s `app-root` wraps it in an app-local `AppRoot` (app modal scope, on
43
+ a full page as well, bounded at `height: 100%`) with the UI locale from
44
+ `locale`; do not add another `AppRoot`.
45
+ In a DSM window (not a full page) that `AppRoot` takes
46
+ `dialog-bounds="viewport"`, so Dialogs can be dragged out of the window, and
47
+ pressing one calls `ctx.host.activateWindow()` to bring the window to the
48
+ front. Elsewhere Dialogs stay inside the App. A confirmed sign-in need sets
49
+ `denied`: the `#denied` slot's `LoginPage` takes the place of the App's pages.
50
+ Import `themes/dsm7-embedded.css` and assign each mount a unique
51
+ `app.config.idPrefix` before mounting.
52
+
53
+ Start with AGENTS.md, `synopkgland skill list` and `synopkgland skill path`.
54
+ Read `synopkgland skill show ui-vue` for direct Vue component work. For UI
55
+ discovery, run `npx --no syno-pkg-land-ui catalog --json` and
56
+ `npx --no syno-pkg-land-ui describe Button --json` from the installed frontend.
@@ -0,0 +1,4 @@
1
+ dist/
2
+ node_modules/
3
+ backend/build/
4
+ backend/app/*.js
@@ -0,0 +1,150 @@
1
+ # __DISPLAY_NAME__
2
+
3
+ A native DSM package (`__PACKAGE__`): a Python App behind the SDK's runtime,
4
+ and a UI that opens in its own DSM window. It installs as an **unsigned** SPK,
5
+ runs as its own DSM package account, needs no containers, and has no root by default.
6
+
7
+ ```text
8
+ DSM main menu → DSM window (ui/app.js)
9
+ │ ctx.call("greeting.get") same-origin POST + DSM session
10
+ ▼
11
+ /webman/3rdparty/__PACKAGE__/cgi/api.cgi (the SDK runtime, runs as package user)
12
+ 1. transport guards: POST, JSON, same origin, ≤ 1 MiB, bounded JSON complexity
13
+ 2. one DSM authentication, the entry gate and app/permissions.json
14
+ 3. DSM's own Python 3 (3.8 or later) runs `backend/app/main.py` once per call
15
+ 4. your method, which may ask the runtime for services (files, jobs, executor)
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ Needs Node.js with `synopkgland` installed, and Python 3.8 or later.
21
+
22
+ ```bash
23
+ (cd backend && python3 -m unittest discover -s tests) # method tests
24
+ synopkgland build # → dist/__PACKAGE__-0.1.0-0001-{x86_64,aarch64}.spk
25
+ synopkgland inspect dist/__PACKAGE__-0.1.0-0001-x86_64.spk
26
+ ```
27
+
28
+ Install: Package Center → Settings → General → Trust Level "Any publisher",
29
+ then Manual Install with the SPK for your NAS's CPU. Open
30
+ **__DISPLAY_NAME__** from the DSM main menu: it shows who you are signed in
31
+ as, what the backend reports, and (for administrators) a form that stores a
32
+ greeting in the package's data folder.
33
+
34
+ ## Project layout
35
+
36
+ | Path | What it is | Edit it? |
37
+ | --- | --- | --- |
38
+ | `dsm-package.yaml` | Package identity, version/build, arches, where backend and UI come from | Yes |
39
+ | `ui/app.js`, `ui/app.css` | The app inside the DSM window | Yes — your UI |
40
+ | `i18n/messages/<locale>.json` | UI text in nine languages | Yes |
41
+ | `i18n/glossary.json` | DSM's own UI terms, and which messages must use them | Add terms |
42
+ | `backend/app/routes.py` | Your methods: `build` registers them | Yes — your backend |
43
+ | `backend/app/jobs.py`, `root_ops.py`, `delegate.py` | Capability examples (stubs unless `init --with` added them) | Yes |
44
+ | `backend/app/permissions.json` | Who may call each of your methods | Yes |
45
+ | `backend/app/main.py` | Runs the App on the runtime's channel | Rarely |
46
+ | `backend/tests/` | unittest tests; `support.call` plays the runtime | Yes |
47
+ | `backend/vendor/synopkgland_app` | The Python SDK for tests, copied by `synopkgland init` from the SDK release; the build ships its own copy of the release `backend.sdk` names | No |
48
+ | `dist/` | Built SPKs (ignored by git) | No |
49
+
50
+ `build` generates everything else: `INFO`, `conf/privilege`
51
+ (`run-as: package`), lifecycle scripts, icons, the DSM window wrapper and the
52
+ runtime CGI at `ui/cgi/api.cgi`.
53
+
54
+ ## Add a method
55
+
56
+ Register it in `build`. Every method defaults to DSM administrator membership;
57
+ all access rules live in `backend/app/permissions.json`:
58
+
59
+ ```python
60
+ def build(cfg):
61
+ app = App()
62
+ ...
63
+ app.method("notes.list")(lambda ctx, params: list_notes(cfg, ctx, params)) # new: administrators by default
64
+ return app
65
+
66
+
67
+ def list_notes(cfg, ctx, params):
68
+ p = fields(params, folder=str) # anything else is bad_request
69
+ return {"owner": ctx.user, "notes": []}
70
+ ```
71
+
72
+ Add a test to `backend/tests/` with `support.call(app, "notes.list", {"folder": "work"}, services={...})`. Call it from the UI with `ctx.call("notes.list", { folder: "work" })`.
73
+
74
+ Raise `Error(code, message)` (or `Error.bad_request(message)`) for an error the browser may see; any other exception is `internal`, with the traceback in the package's log. A refusal from `ctx.runtime` is an `Error` too, so the browser sees the runtime's code.
75
+
76
+ The runtime refuses before your App runs: `session_required`, `admin_required`,
77
+ `unknown_method`, `request_timeout` and the transport guards' codes. The UI
78
+ shows `error.<code>` from its catalogs.
79
+
80
+ ### Who can call what
81
+
82
+ The shipped policy is:
83
+
84
+ ```json
85
+ {"schema": 1, "default": "administrators", "methods": {}}
86
+ ```
87
+
88
+ The default must remain `administrators`. An intentional exception belongs in
89
+ `methods`, for example `"notes.list": "authenticated"`; `"public"` explicitly
90
+ allows anonymous calls. Such exceptions also enable ordinary-user launcher
91
+ access.
92
+
93
+ | JSON rule | No session | Ordinary user | Administrators group member |
94
+ | --- | --- | --- | --- |
95
+ | absent or `administrators` | `session_required` | `admin_required` | runs |
96
+ | explicit `authenticated` | `session_required` | runs | runs |
97
+ | explicit `public` | runs | runs | runs |
98
+ | method the App does not register | `session_required` | `unknown_method` | `unknown_method` |
99
+
100
+ Missing or invalid JSON, duplicate keys, unknown permissions and rules for
101
+ methods the App does not register fail closed. Changing policy requires
102
+ rebuilding the SPK.
103
+
104
+ ## Capabilities
105
+
106
+ `synopkgland init --with` adds examples that use the runtime's services through
107
+ `ctx.runtime`; each comes with its tests:
108
+
109
+ | `--with` | Methods | Runtime services |
110
+ | --- | --- | --- |
111
+ | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `files.read` with the caller's session, `bg.submit`, `bg.get`, `bg.cancel` |
112
+ | `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
113
+ | `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
114
+
115
+ A service refuses with its own code (`limit_exceeded`, `read_failed`, the
116
+ executor's refusals); the examples pass it on to the browser. `synopkgland add`
117
+ works on Go backends only: start a Python App with the capabilities it needs.
118
+
119
+ ## The SDK
120
+
121
+ `backend.sdk` in dsm-package.yaml names the SDK release: the build ships its runtime and its Python SDK as `backend/app/vendor/synopkgland_app`, and reads the method list from `main.py --describe`. `backend/vendor/synopkgland_app` is the same SDK for tests; replace it with the new release's when you change `backend.sdk`. Upgrade the SDK by changing that version; `synopkgland check`
122
+ refuses versions this CLI does not support. The bridge protocol, the services
123
+ and their codes are documented in the SDK repository:
124
+ <https://github.com/synopackageland/syno-pkg-land-sdk> (`spec/bridge/v1`).
125
+
126
+ ## UI and languages
127
+
128
+ The UI is the same as the Go template's. `ui/app.js` registers
129
+ `window.DsmPackageApps[<package>].mount(root, ctx)`; `ctx.call(method, params)`
130
+ is the only transport and `ctx.t(key, values)` reads
131
+ `i18n/messages/<locale>.json` (`en`, `zh-TW`, `zh-CN`, `ja`, `ko`, `de`,
132
+ `fr`, `es`, `it`). Add `error.<code>` for every code your methods return.
133
+ `synopkgland check` and `build` refuse a language file with missing or extra
134
+ keys, a dropped `{placeholder}` or a listed message that drifts from the
135
+ glossary. `--ui vue` gives the Vue shell in `frontend/` instead.
136
+
137
+ ## Data, upgrades, uninstall
138
+
139
+ The App stores data in `/var/packages/__PACKAGE__/var` (`SYNOPKGLAND_DATA_DIR`,
140
+ kept across upgrades). Bump `build` for every SPK you hand out and `version`
141
+ for feature releases. Uninstalling keeps the data unless the administrator
142
+ ticks **Also delete the app data listed above** in the uninstall wizard.
143
+
144
+ ## Support matrix
145
+
146
+ | | Status |
147
+ | --- | --- |
148
+ | Build hosts | macOS, Linux |
149
+ | Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
150
+ | DSM | 7.1 or later declared (`os_min_ver`) |
@@ -0,0 +1,5 @@
1
+ """Saves to a user's destination: `synopkgland init --with delegate` replaces this stub with an example."""
2
+
3
+
4
+ def register(app, cfg):
5
+ pass
@@ -0,0 +1,5 @@
1
+ """Background jobs: `synopkgland init --with jobs` replaces this stub with an example."""
2
+
3
+
4
+ def register(app, cfg):
5
+ pass
@@ -0,0 +1,5 @@
1
+ """The App: the runtime starts it once per call (spec/bridge/v1)."""
2
+
3
+ import routes
4
+
5
+ routes.build(routes.Config.from_env()).run()
@@ -0,0 +1,10 @@
1
+ """Request params, checked strictly: the browser sends what it likes."""
2
+
3
+ from synopkgland_app import Error
4
+
5
+
6
+ def fields(params, **types):
7
+ """params as an object with exactly these fields of these types, else bad_request."""
8
+ if not isinstance(params, dict) or set(params) != set(types) or not all(isinstance(params[k], t) for k, t in types.items()):
9
+ raise Error.bad_request("params must be {%s}" % ", ".join(sorted(types)))
10
+ return params
@@ -0,0 +1,5 @@
1
+ {
2
+ "schema": 1,
3
+ "default": "administrators",
4
+ "methods": {}
5
+ }
@@ -0,0 +1,5 @@
1
+ """Root operations: `synopkgland init --with root-ops` replaces this stub with an example."""
2
+
3
+
4
+ def register(app, cfg):
5
+ pass
@@ -0,0 +1,107 @@
1
+ """The App's methods. Register a method in build() and give it a rule in
2
+ permissions.json unless only administrators may call it: the runtime has
3
+ checked the DSM session, the entry gate and that rule before a handler runs.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import platform
9
+ import sys
10
+ import tempfile
11
+ import time
12
+
13
+ from synopkgland_app import App, Error
14
+
15
+ import delegate
16
+ import jobs
17
+ import root_ops
18
+ from params import fields
19
+
20
+
21
+ class Config:
22
+ """Where the App's state lives; the runtime sets the environment."""
23
+
24
+ def __init__(self, package, data_dir, packages_root="/var/packages"):
25
+ self.package = package
26
+ # The package's own data directory (/var/packages/<package>/var), kept across upgrades.
27
+ self.data_dir = data_dir
28
+ self.packages_root = packages_root
29
+
30
+ @classmethod
31
+ def from_env(cls):
32
+ return cls(os.environ.get("SYNOPKGLAND_PACKAGE", ""), os.environ.get("SYNOPKGLAND_DATA_DIR", ""))
33
+
34
+
35
+ def build(cfg):
36
+ app = App()
37
+ app.method("app.status")(lambda ctx, params: status(cfg, ctx))
38
+ app.method("greeting.get")(lambda ctx, params: get_greeting(cfg))
39
+ app.method("greeting.set")(lambda ctx, params: set_greeting(cfg, ctx, params))
40
+ # Each capability module is a stub until `synopkgland init --with` adds its example.
41
+ for capability in (jobs, root_ops, delegate):
42
+ capability.register(app, cfg)
43
+ return app
44
+
45
+
46
+ def status(cfg, ctx):
47
+ result = {
48
+ "package": cfg.package,
49
+ "version": installed_version(cfg),
50
+ "user": ctx.user,
51
+ "admin": ctx.admin,
52
+ "process": {"uid": os.getuid(), "gid": os.getgid()},
53
+ "language": "Python " + platform.python_version(),
54
+ "platform": sys.platform + "/" + platform.machine(),
55
+ "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
56
+ }
57
+ try:
58
+ # Served only when dsm-package.yaml declares a portal.
59
+ result["portal"] = ctx.runtime("portal.status")
60
+ except Error:
61
+ pass
62
+ return result
63
+
64
+
65
+ def installed_version(cfg):
66
+ """The version in the package's INFO, or "" outside DSM."""
67
+ try:
68
+ with open(os.path.join(cfg.packages_root, cfg.package, "INFO"), encoding="utf-8") as info:
69
+ for line in info:
70
+ if line.startswith("version="):
71
+ return line[len("version=") :].strip().strip('"')
72
+ except OSError:
73
+ pass
74
+ return ""
75
+
76
+
77
+ def greeting_path(cfg):
78
+ return os.path.join(cfg.data_dir, "greeting.json")
79
+
80
+
81
+ def get_greeting(cfg):
82
+ try:
83
+ with open(greeting_path(cfg), encoding="utf-8") as f:
84
+ return json.load(f)
85
+ except FileNotFoundError:
86
+ return {"text": "Hello"}
87
+
88
+
89
+ def set_greeting(cfg, ctx, params):
90
+ text = fields(params, text=str)["text"]
91
+ if not 1 <= len(text) <= 200:
92
+ raise Error.bad_request("text must be 1 to 200 characters")
93
+ greeting = {"text": text, "updated_by": ctx.user}
94
+ write_file_atomic(greeting_path(cfg), json.dumps(greeting).encode())
95
+ return greeting
96
+
97
+
98
+ def write_file_atomic(path, data):
99
+ """Replace path in one rename, so readers never see half a file."""
100
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".tmp-")
101
+ try:
102
+ with os.fdopen(fd, "wb") as f:
103
+ f.write(data)
104
+ os.replace(tmp, path)
105
+ except BaseException:
106
+ os.unlink(tmp)
107
+ raise