@synopackageland/templates 0.9.8 → 0.10.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/README.md +27 -27
- 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/glossary.json +3 -1
- package/templates/native-go/i18n/messages/de.json +6 -0
- package/templates/native-go/i18n/messages/en.json +6 -0
- package/templates/native-go/i18n/messages/es.json +6 -0
- package/templates/native-go/i18n/messages/fr.json +6 -0
- package/templates/native-go/i18n/messages/it.json +6 -0
- package/templates/native-go/i18n/messages/ja.json +6 -0
- package/templates/native-go/i18n/messages/ko.json +6 -0
- package/templates/native-go/i18n/messages/zh-CN.json +6 -0
- package/templates/native-go/i18n/messages/zh-TW.json +6 -0
- package/templates/native-go/ui/app.js +2 -1
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +3 -2
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
- package/templates/native-go-vue/README.md +19 -7
- 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 +31 -36
- 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 -663
- package/templates/native-rust/i18n/messages/de.json +0 -30
- package/templates/native-rust/i18n/messages/en.json +0 -30
- package/templates/native-rust/i18n/messages/es.json +0 -30
- package/templates/native-rust/i18n/messages/fr.json +0 -30
- package/templates/native-rust/i18n/messages/it.json +0 -30
- package/templates/native-rust/i18n/messages/ja.json +0 -30
- package/templates/native-rust/i18n/messages/ko.json +0 -30
- package/templates/native-rust/i18n/messages/zh-CN.json +0 -30
- package/templates/native-rust/i18n/messages/zh-TW.json +0 -30
- 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,97 @@
|
|
|
1
|
+
// The App's methods. Register a method in build() and give it a rule in
|
|
2
|
+
// app/permissions.json unless only administrators may call it: the runtime
|
|
3
|
+
// has checked the DSM session, the entry gate and that rule before a handler
|
|
4
|
+
// runs.
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
import { App, AppError, type Context } from '@synopackageland/app'
|
|
9
|
+
import * as delegate from './delegate.js'
|
|
10
|
+
import * as jobs from './jobs.js'
|
|
11
|
+
import { strings } from './params.js'
|
|
12
|
+
import * as rootOps from './root-ops.js'
|
|
13
|
+
|
|
14
|
+
/** Where the App's state lives; the runtime sets the environment. */
|
|
15
|
+
export interface Config {
|
|
16
|
+
packageId: string
|
|
17
|
+
/** The package's own data directory (/var/packages/<package>/var), kept across upgrades. */
|
|
18
|
+
dataDir: string
|
|
19
|
+
packagesRoot: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function configFromEnv(): Config {
|
|
23
|
+
const env = process.env
|
|
24
|
+
return { packageId: env.SYNOPKGLAND_PACKAGE ?? '', dataDir: env.SYNOPKGLAND_DATA_DIR ?? '', packagesRoot: '/var/packages' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function build(cfg: Config): App {
|
|
28
|
+
const app = new App()
|
|
29
|
+
app.method('app.status', (ctx) => status(cfg, ctx))
|
|
30
|
+
app.method('greeting.get', () => getGreeting(cfg))
|
|
31
|
+
app.method('greeting.set', (ctx, params) => setGreeting(cfg, ctx, params))
|
|
32
|
+
// Each capability module is a stub until `synopkgland init --with` adds its example.
|
|
33
|
+
for (const capability of [jobs, rootOps, delegate]) capability.register(app, cfg)
|
|
34
|
+
return app
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function status(cfg: Config, ctx: Context) {
|
|
38
|
+
const result: Record<string, unknown> = {
|
|
39
|
+
package: cfg.packageId,
|
|
40
|
+
version: installedVersion(cfg),
|
|
41
|
+
user: ctx.user,
|
|
42
|
+
admin: ctx.admin,
|
|
43
|
+
process: { uid: process.getuid!(), gid: process.getgid!() },
|
|
44
|
+
language: `Node.js ${process.versions.node}`,
|
|
45
|
+
platform: `${process.platform}/${process.arch}`,
|
|
46
|
+
time: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
// Served only when dsm-package.yaml declares a portal.
|
|
50
|
+
result.portal = await ctx.runtime('portal.status')
|
|
51
|
+
} catch {
|
|
52
|
+
// No portal.
|
|
53
|
+
}
|
|
54
|
+
return result
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The version in the package's INFO, or "" outside DSM. */
|
|
58
|
+
function installedVersion(cfg: Config): string {
|
|
59
|
+
try {
|
|
60
|
+
const info = readFileSync(join(cfg.packagesRoot, cfg.packageId, 'INFO'), 'utf8')
|
|
61
|
+
return /^version="?([^"\n]*)"?$/m.exec(info)?.[1] ?? ''
|
|
62
|
+
} catch {
|
|
63
|
+
return ''
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const greetingPath = (cfg: Config) => join(cfg.dataDir, 'greeting.json')
|
|
68
|
+
|
|
69
|
+
function getGreeting(cfg: Config): unknown {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(readFileSync(greetingPath(cfg), 'utf8'))
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { text: 'Hello' }
|
|
74
|
+
throw e
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setGreeting(cfg: Config, ctx: Context, params: unknown) {
|
|
79
|
+
const { text } = strings(params, 'text')
|
|
80
|
+
const length = [...text].length
|
|
81
|
+
if (length < 1 || length > 200) throw AppError.badRequest('text must be 1 to 200 characters')
|
|
82
|
+
const greeting = { text, updated_by: ctx.user }
|
|
83
|
+
writeFileAtomic(greetingPath(cfg), JSON.stringify(greeting))
|
|
84
|
+
return greeting
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Replaces path in one rename, so readers never see half a file. */
|
|
88
|
+
export function writeFileAtomic(path: string, data: string): void {
|
|
89
|
+
const tmp = join(dirname(path), `.tmp-${randomUUID()}`)
|
|
90
|
+
try {
|
|
91
|
+
writeFileSync(tmp, data, { flag: 'wx' })
|
|
92
|
+
renameSync(tmp, path)
|
|
93
|
+
} catch (e) {
|
|
94
|
+
try { unlinkSync(tmp) } catch { /* never written */ }
|
|
95
|
+
throw e
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The runtime's services, with their refusals passed on to the browser.
|
|
2
|
+
import { AppError, ServiceError, type Context } from '@synopackageland/app'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ctx.runtime. A refusal the browser may see (limit_exceeded, read_failed,
|
|
6
|
+
* SCOPE_DENIED, ...) reaches it as the App's own error; any other code, such
|
|
7
|
+
* as the runtime's session_required, stays internal.
|
|
8
|
+
*/
|
|
9
|
+
export async function service<T>(ctx: Context, op: string, params: unknown = null): Promise<T> {
|
|
10
|
+
try {
|
|
11
|
+
return (await ctx.runtime(op, params)) as T
|
|
12
|
+
} catch (e) {
|
|
13
|
+
if (!(e instanceof ServiceError)) throw e
|
|
14
|
+
let passed: AppError
|
|
15
|
+
try {
|
|
16
|
+
passed = new AppError(e.code, e.message)
|
|
17
|
+
} catch {
|
|
18
|
+
throw e
|
|
19
|
+
}
|
|
20
|
+
throw passed
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } 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 { call } from './support.js'
|
|
8
|
+
|
|
9
|
+
let root: string
|
|
10
|
+
let cfg: Config
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
root = mkdtempSync(join(tmpdir(), 'app-'))
|
|
14
|
+
cfg = { packageId: 'ExampleApp', dataDir: join(root, 'var'), packagesRoot: join(root, 'packages') }
|
|
15
|
+
mkdirSync(cfg.dataDir)
|
|
16
|
+
})
|
|
17
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }))
|
|
18
|
+
|
|
19
|
+
function writeInfo(text: string) {
|
|
20
|
+
mkdirSync(join(cfg.packagesRoot, 'ExampleApp'), { recursive: true })
|
|
21
|
+
writeFileSync(join(cfg.packagesRoot, 'ExampleApp', 'INFO'), text)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('app.status reports the caller and the installed version', async () => {
|
|
25
|
+
writeInfo('package="ExampleApp"\nversion="0.1.0-0001"\n')
|
|
26
|
+
const reply = await call(build(cfg), 'app.status', null, { user: 'alice', admin: false })
|
|
27
|
+
assert.equal(reply.ok, true, JSON.stringify(reply.error))
|
|
28
|
+
const status = reply.data
|
|
29
|
+
assert.deepEqual([status.package, status.version, status.user, status.admin], ['ExampleApp', '0.1.0-0001', 'alice', false])
|
|
30
|
+
assert.deepEqual(status.process, { uid: process.getuid!(), gid: process.getgid!() })
|
|
31
|
+
assert.match(status.language, /^Node\.js \d+\./)
|
|
32
|
+
assert.match(status.platform, /^\w+\/\w+$/)
|
|
33
|
+
assert.match(status.time, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('app.status without INFO has no version', async () => {
|
|
37
|
+
assert.equal((await call(build(cfg), 'app.status')).data.version, '')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('app.status shows the portal when the runtime offers it', async () => {
|
|
41
|
+
const portal = { enabled: true, alias: 'example' }
|
|
42
|
+
const reply = await call(build(cfg), 'app.status', null, { services: { 'portal.status': () => portal } })
|
|
43
|
+
assert.deepEqual(reply.data.portal, portal)
|
|
44
|
+
assert.deepEqual(reply.services, [['portal.status', null]])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('app.status without a portal leaves it out', async () => {
|
|
48
|
+
const reply = await call(build(cfg), 'app.status')
|
|
49
|
+
assert.equal(reply.ok, true, JSON.stringify(reply.error))
|
|
50
|
+
assert.equal('portal' in reply.data, false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('greeting.get defaults to Hello', async () => {
|
|
54
|
+
assert.deepEqual((await call(build(cfg), 'greeting.get')).data, { text: 'Hello' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('greeting.set stores the text and who set it', async () => {
|
|
58
|
+
const app = build(cfg)
|
|
59
|
+
const reply = await call(app, 'greeting.set', { text: 'Hi 👋' }, { user: 'alice' })
|
|
60
|
+
assert.deepEqual(reply.data, { text: 'Hi 👋', updated_by: 'alice' })
|
|
61
|
+
assert.deepEqual((await call(app, 'greeting.get')).data, { text: 'Hi 👋', updated_by: 'alice' })
|
|
62
|
+
assert.deepEqual(readdirSync(cfg.dataDir), ['greeting.json'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('greeting.set counts characters, not UTF-16 units', async () => {
|
|
66
|
+
assert.equal((await call(build(cfg), 'greeting.set', { text: '👋'.repeat(200) })).ok, true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('greeting.set refuses invalid params and keeps the old text', async () => {
|
|
70
|
+
for (const params of [null, [], {}, { text: '' }, { text: 'x'.repeat(201) }, { text: 5 }, { text: 'hi', extra: 1 }]) {
|
|
71
|
+
const reply = await call(build(cfg), 'greeting.set', params)
|
|
72
|
+
assert.equal(reply.error?.code, 'bad_request', JSON.stringify(params))
|
|
73
|
+
}
|
|
74
|
+
assert.deepEqual(readdirSync(cfg.dataDir), [])
|
|
75
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Calls an App method the way the runtime does: over a channel, with the
|
|
2
|
+
// hello, the call, the services the method asks for and its result.
|
|
3
|
+
import { PassThrough } from 'node:stream'
|
|
4
|
+
import { createInterface } from 'node:readline'
|
|
5
|
+
import type { App } from '@synopackageland/app'
|
|
6
|
+
|
|
7
|
+
/** A service's refusal: the runtime answers the svc with this code. */
|
|
8
|
+
export class Refusal extends Error {
|
|
9
|
+
constructor(readonly code: string, message = code) {
|
|
10
|
+
super(message)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Each op the method may ask for: returns the result or throws a Refusal. An op it lacks is unknown_service. */
|
|
15
|
+
export type Services = Record<string, (params: unknown) => unknown>
|
|
16
|
+
|
|
17
|
+
export interface Reply {
|
|
18
|
+
ok: boolean
|
|
19
|
+
data?: any
|
|
20
|
+
error?: { code: string; message: string }
|
|
21
|
+
/** The services the method asked for, in order. */
|
|
22
|
+
services: [string, unknown][]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Caller {
|
|
26
|
+
user?: string
|
|
27
|
+
admin?: boolean
|
|
28
|
+
services?: Services
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function call(app: App, method: string, params: unknown = null, caller: Caller = {}): Promise<Reply> {
|
|
32
|
+
const { user = 'admin', admin = true, services = {} } = caller
|
|
33
|
+
const toApp = new PassThrough()
|
|
34
|
+
const fromApp = new PassThrough()
|
|
35
|
+
const serving = app.serve(toApp, fromApp)
|
|
36
|
+
const send = (frame: object) => toApp.write(JSON.stringify(frame) + '\n')
|
|
37
|
+
const lines = createInterface({ input: fromApp })[Symbol.asyncIterator]()
|
|
38
|
+
send({ t: 'hello', versions: [1] })
|
|
39
|
+
await lines.next()
|
|
40
|
+
const ctx = { user, admin, locale: 'en', request_id: '0'.repeat(32), deadline_ms: 30000 }
|
|
41
|
+
send({ t: 'call', id: '1', method, params, ctx })
|
|
42
|
+
const asked: [string, unknown][] = []
|
|
43
|
+
try {
|
|
44
|
+
for (let line = await lines.next(); !line.done; line = await lines.next()) {
|
|
45
|
+
const frame = JSON.parse(line.value)
|
|
46
|
+
if (frame.t === 'result') return { ok: frame.ok, data: frame.data, error: frame.error, services: asked }
|
|
47
|
+
asked.push([frame.op, frame.params])
|
|
48
|
+
send(await answer(services, frame))
|
|
49
|
+
}
|
|
50
|
+
throw new Error('the App ended the channel without a result')
|
|
51
|
+
} finally {
|
|
52
|
+
toApp.end()
|
|
53
|
+
await serving
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function answer(services: Services, frame: { id: string; op: string; params: unknown }): Promise<object> {
|
|
58
|
+
const reply = { t: 'svc_result', id: frame.id }
|
|
59
|
+
try {
|
|
60
|
+
const service = services[frame.op]
|
|
61
|
+
if (!service) throw new Refusal('unknown_service', `no service ${frame.op}`)
|
|
62
|
+
return { ...reply, ok: true, result: await service(frame.params) }
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (!(e instanceof Refusal)) throw e
|
|
65
|
+
return { ...reply, ok: false, error: { code: e.code, message: e.message } }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"exactOptionalPropertyTypes": true,
|
|
10
|
+
"noUnusedLocals": true,
|
|
11
|
+
"noUnusedParameters": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"rootDir": ".",
|
|
15
|
+
"outDir": "build"
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*.ts", "test/**/*.ts"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Standalone native DSM package: an unsigned SPK that runs as its own package
|
|
2
|
+
# `synopkgland build` reads this file.
|
|
3
|
+
schema: 2
|
|
4
|
+
|
|
5
|
+
# INFO.package: letters and digits, starting with a letter. It also names the
|
|
6
|
+
# DSM app (SYNO.SDS.<package>) and the CGI URL /webman/3rdparty/<package>/.
|
|
7
|
+
package: __PACKAGE__
|
|
8
|
+
version: 0.1.0
|
|
9
|
+
# Increase for every SPK you hand out with the same version (INFO 0.1.0-0001).
|
|
10
|
+
build: 1
|
|
11
|
+
displayname: __DISPLAY_NAME__
|
|
12
|
+
description: A native DSM package with a Node.js backend and a DSM window UI.
|
|
13
|
+
# Package Center and DSM window icon: a square PNG of at least 256x256, or a
|
|
14
|
+
# folder of icon_<size>.png. Omit to use the SDK default icon.
|
|
15
|
+
# icon: assets/icon.png
|
|
16
|
+
maintainer: Your Name
|
|
17
|
+
# Optional INFO/website metadata. Omit fields you do not need.
|
|
18
|
+
# metadata:
|
|
19
|
+
# contact_email: you@example.com
|
|
20
|
+
# support_email: support@example.com
|
|
21
|
+
# homepage: https://example.com
|
|
22
|
+
# source_url: https://github.com/you/your-app
|
|
23
|
+
# issues_url: https://github.com/you/your-app/issues
|
|
24
|
+
# license: MIT
|
|
25
|
+
# long_description:
|
|
26
|
+
# en: |-
|
|
27
|
+
# Explain what the app does and how to use it.
|
|
28
|
+
# zh-TW: Translated description.
|
|
29
|
+
# changelog: # text, or per-language like long_description
|
|
30
|
+
# en: |-
|
|
31
|
+
# - Most important change first. Omit to use Release notes.
|
|
32
|
+
# zh-TW: |-
|
|
33
|
+
# - Translated changes.
|
|
34
|
+
# screenshots: [https://example.com/screenshot.png]
|
|
35
|
+
os_min_ver: "7.1-42661"
|
|
36
|
+
|
|
37
|
+
# One SPK per architecture. Supported: x86_64, aarch64, armv7 (add it here to
|
|
38
|
+
# build it; armv7 is built but not NAS-verified).
|
|
39
|
+
arches: [x86_64, aarch64]
|
|
40
|
+
|
|
41
|
+
# The SDK's Go runtime is the CGI: it checks the DSM session, the entry gate
|
|
42
|
+
# and app/permissions.json, then runs `node main.js` in backend/app as the
|
|
43
|
+
# package account, once per call, with Package Center's Node.js (node.package
|
|
44
|
+
# becomes an install dependency). backend/src compiles to backend/app: run
|
|
45
|
+
# `npm ci` and `npm run build` in backend before check and build. sdk is the
|
|
46
|
+
# SDK release whose runtime and Node SDK (node_modules/@synopackageland/app)
|
|
47
|
+
# the build ships; the build reads the methods from `main.js --describe`.
|
|
48
|
+
backend:
|
|
49
|
+
runtime: node
|
|
50
|
+
dir: backend/app
|
|
51
|
+
entry: main.js
|
|
52
|
+
sdk: 0.10.0
|
|
53
|
+
node: { package: Node.js_v20 }
|
|
54
|
+
|
|
55
|
+
ui:
|
|
56
|
+
# Static files served at /webman/3rdparty/<package>/app/. app.js and app.css
|
|
57
|
+
# are required; anything else (images, chunks) is copied as is.
|
|
58
|
+
dir: ui
|
|
59
|
+
|
|
60
|
+
# UI languages: i18n/messages/<locale>.json for en, zh-TW, zh-CN, ja, ko, de,
|
|
61
|
+
# fr, es and it (DSM's other languages fall back to English). build checks
|
|
62
|
+
# that every language has the en.json keys and placeholders, and that the keys
|
|
63
|
+
# listed in the glossary use DSM's own wording.
|
|
64
|
+
i18n:
|
|
65
|
+
messages: i18n/messages
|
|
66
|
+
glossary: i18n/glossary.json
|
|
67
|
+
# Package Center text per language (INFO displayname_<lang>/description_<lang>).
|
|
68
|
+
# English comes from displayname/description above.
|
|
69
|
+
info:
|
|
70
|
+
zh-TW: { description: 以 Node.js 後端與 DSM 視窗介面打造的原生 DSM 套件。 }
|
|
71
|
+
zh-CN: { description: 以 Node.js 后端与 DSM 窗口界面构建的原生 DSM 套件。 }
|
|
72
|
+
ja: { description: Node.js バックエンドと DSM ウィンドウ UI を備えたネイティブ DSM パッケージ。 }
|
|
73
|
+
ko: { description: Node.js 백엔드와 DSM 창 UI를 갖춘 네이티브 DSM 패키지. }
|
|
74
|
+
de: { description: Ein natives DSM-Paket mit Node.js-Backend und DSM-Fenster-Oberfläche. }
|
|
75
|
+
fr: { description: Un paquet DSM natif avec un backend Node.js et une interface en fenêtre DSM. }
|
|
76
|
+
es: { description: Un paquete nativo de DSM con backend en Node.js e interfaz en ventana de DSM. }
|
|
77
|
+
it: { description: Un pacchetto DSM nativo con backend Node.js e interfaccia in una finestra DSM. }
|
|
78
|
+
|
|
79
|
+
# Optional SDK capabilities; delegate/rootOps share administrator-approved executor setup.
|
|
80
|
+
capabilities: {}
|
|
@@ -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
|
+
})
|