@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.
- package/README.md +29 -29
- package/package.json +2 -2
- package/templates/_shared/AGENTS.md +53 -15
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
- package/templates/native-go/backend/cmd/app/main.go +15 -1
- package/templates/native-go/backend/cmd/app/main_test.go +33 -0
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/go.sum +2 -2
- package/templates/native-go/i18n/messages/de.json +4 -0
- package/templates/native-go/i18n/messages/en.json +4 -0
- package/templates/native-go/i18n/messages/es.json +4 -0
- package/templates/native-go/i18n/messages/fr.json +4 -0
- package/templates/native-go/i18n/messages/it.json +4 -0
- package/templates/native-go/i18n/messages/ja.json +4 -0
- package/templates/native-go/i18n/messages/ko.json +4 -0
- package/templates/native-go/i18n/messages/zh-CN.json +4 -0
- package/templates/native-go/i18n/messages/zh-TW.json +4 -0
- package/templates/native-go/ui/app.js +2 -1
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +24 -3
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
- package/templates/native-go-root-ops/backend/internal/app/executor.go +2 -65
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +10 -82
- package/templates/native-go-root-ops/frontend/src/ExecutorGate.vue +16 -0
- package/templates/native-go-root-ops/frontend/test/executor-gate.test.ts +60 -0
- package/templates/native-go-vue/README.md +24 -8
- package/templates/native-go-vue/frontend/package-lock.json +9 -9
- package/templates/native-go-vue/frontend/package.json +2 -2
- package/templates/native-go-vue/frontend/src/App.vue +36 -36
- package/templates/native-go-vue/frontend/src/ExecutorGate.vue +10 -0
- package/templates/native-go-vue/frontend/test/apps.test.ts +65 -12
- package/templates/native-go-vue/frontend/test/context.ts +8 -0
- package/templates/native-go-vue/frontend/test/native-controls.test.ts +3 -4
- package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +2 -2
- package/templates/native-node/README.md +153 -0
- package/templates/native-node/backend/app/package.json +4 -0
- package/templates/native-node/backend/app/permissions.json +5 -0
- package/templates/native-node/backend/package-lock.json +59 -0
- package/templates/native-node/backend/package.json +17 -0
- package/templates/native-node/backend/src/delegate.ts +5 -0
- package/templates/native-node/backend/src/jobs.ts +5 -0
- package/templates/native-node/backend/src/main.ts +4 -0
- package/templates/native-node/backend/src/params.ts +11 -0
- package/templates/native-node/backend/src/root-ops.ts +5 -0
- package/templates/native-node/backend/src/routes.ts +97 -0
- package/templates/native-node/backend/src/service.ts +22 -0
- package/templates/native-node/backend/test/routes.test.ts +75 -0
- package/templates/native-node/backend/test/support.ts +67 -0
- package/templates/native-node/backend/tsconfig.build.json +8 -0
- package/templates/native-node/backend/tsconfig.json +18 -0
- package/templates/native-node/dsm-package.yaml +80 -0
- package/templates/native-node/gitignore +4 -0
- package/templates/native-node-delegate/backend/src/delegate.ts +35 -0
- package/templates/native-node-delegate/backend/test/delegate.test.ts +51 -0
- package/templates/native-node-jobs/backend/src/jobs.ts +38 -0
- package/templates/native-node-jobs/backend/test/jobs.test.ts +73 -0
- package/templates/native-node-root-ops/backend/src/root-ops.ts +16 -0
- package/templates/native-node-root-ops/backend/test/root-ops.test.ts +23 -0
- package/templates/native-node-vue/README.md +56 -0
- package/templates/native-node-vue/gitignore +4 -0
- package/templates/native-python/README.md +150 -0
- package/templates/native-python/backend/app/delegate.py +5 -0
- package/templates/native-python/backend/app/jobs.py +5 -0
- package/templates/native-python/backend/app/main.py +5 -0
- package/templates/native-python/backend/app/params.py +10 -0
- package/templates/native-python/backend/app/permissions.json +5 -0
- package/templates/native-python/backend/app/root_ops.py +5 -0
- package/templates/native-python/backend/app/routes.py +107 -0
- package/templates/native-python/backend/tests/support.py +72 -0
- package/templates/native-python/backend/tests/test_routes.py +73 -0
- package/templates/native-python/dsm-package.yaml +77 -0
- package/templates/native-python/gitignore +2 -0
- package/templates/native-python-delegate/backend/app/delegate.py +42 -0
- package/templates/native-python-delegate/backend/tests/test_delegate.py +58 -0
- package/templates/native-python-jobs/backend/app/jobs.py +40 -0
- package/templates/native-python-jobs/backend/tests/test_jobs.py +72 -0
- package/templates/native-python-root-ops/backend/app/root_ops.py +18 -0
- package/templates/native-python-root-ops/backend/tests/test_root_ops.py +34 -0
- package/templates/native-python-vue/README.md +56 -0
- package/templates/native-python-vue/gitignore +3 -0
- package/templates/native-rust/README.md +74 -296
- package/templates/native-rust/backend/Cargo.lock +6 -6
- package/templates/native-rust/backend/Cargo.toml +2 -10
- package/templates/native-rust/backend/src/delegate.rs +6 -0
- package/templates/native-rust/backend/src/jobs.rs +6 -0
- package/templates/native-rust/backend/src/main.rs +12 -0
- package/templates/native-rust/backend/src/root_ops.rs +6 -0
- package/templates/native-rust/backend/src/routes.rs +352 -0
- package/templates/native-rust/backend/src/test_support.rs +91 -0
- package/templates/native-rust/dsm-package.yaml +10 -21
- package/templates/native-rust-delegate/backend/src/delegate.rs +171 -0
- package/templates/native-rust-jobs/backend/src/jobs.rs +240 -0
- package/templates/native-rust-root-ops/backend/src/root_ops.rs +83 -0
- package/templates/native-rust-vue/README.md +35 -34
- package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +0 -207
- package/templates/native-rust/backend/src/app/background/mod.rs +0 -424
- package/templates/native-rust/backend/src/app/background/owner.rs +0 -77
- package/templates/native-rust/backend/src/app/background/owner_tests.rs +0 -174
- package/templates/native-rust/backend/src/app/background/schedule.rs +0 -126
- package/templates/native-rust/backend/src/app/background/schedule_tests.rs +0 -108
- package/templates/native-rust/backend/src/app/background/tests.rs +0 -505
- package/templates/native-rust/backend/src/app/background/work.rs +0 -78
- package/templates/native-rust/backend/src/app/background/work_tests.rs +0 -123
- package/templates/native-rust/backend/src/app/background_routes.rs +0 -22
- package/templates/native-rust/backend/src/app/delegate_config.rs +0 -188
- package/templates/native-rust/backend/src/app/delegate_routes.rs +0 -22
- package/templates/native-rust/backend/src/app/identity.rs +0 -56
- package/templates/native-rust/backend/src/app/migrations.rs +0 -79
- package/templates/native-rust/backend/src/app/mod.rs +0 -30
- package/templates/native-rust/backend/src/app/portal.rs +0 -317
- package/templates/native-rust/backend/src/app/root_routes.rs +0 -23
- package/templates/native-rust/backend/src/app/routes.rs +0 -315
- package/templates/native-rust/backend/src/app/session_files.rs +0 -19
- package/templates/native-rust/backend/src/app/shared_folder.rs +0 -383
- package/templates/native-rust/backend/src/app/test_support.rs +0 -202
- package/templates/native-rust/backend/src/app/uninstall.rs +0 -38
- package/templates/native-rust/backend/src/bin/api.rs +0 -72
- package/templates/native-rust/backend/src/bin/background-worker.rs +0 -37
- package/templates/native-rust/backend/src/build_env.rs +0 -155
- package/templates/native-rust/backend/src/lib.rs +0 -6
- package/templates/native-rust/backend/src/permissions.rs +0 -54
- package/templates/native-rust/backend/src/process.rs +0 -91
- package/templates/native-rust/i18n/glossary.json +0 -665
- package/templates/native-rust/i18n/messages/de.json +0 -32
- package/templates/native-rust/i18n/messages/en.json +0 -32
- package/templates/native-rust/i18n/messages/es.json +0 -32
- package/templates/native-rust/i18n/messages/fr.json +0 -32
- package/templates/native-rust/i18n/messages/it.json +0 -32
- package/templates/native-rust/i18n/messages/ja.json +0 -32
- package/templates/native-rust/i18n/messages/ko.json +0 -32
- package/templates/native-rust/i18n/messages/zh-CN.json +0 -32
- package/templates/native-rust/i18n/messages/zh-TW.json +0 -32
- package/templates/native-rust/ui/app.css +0 -45
- package/templates/native-rust/ui/app.js +0 -111
- package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +0 -92
- package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +0 -52
- package/templates/native-rust-jobs/backend/src/app/background_routes.rs +0 -109
- 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,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,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,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
|