@owlmeans/server-job 0.1.18-rc.2 → 0.1.18-rc.21
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 +36 -86
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-job/SKILL.md +60 -107
- package/build/actions/cancel.d.ts +3 -4
- package/build/actions/cancel.d.ts.map +1 -1
- package/build/actions/cancel.js +11 -6
- package/build/actions/cancel.js.map +1 -1
- package/build/actions/get.d.ts +3 -4
- package/build/actions/get.d.ts.map +1 -1
- package/build/actions/get.js +6 -4
- package/build/actions/get.js.map +1 -1
- package/build/actions/list.d.ts +3 -4
- package/build/actions/list.d.ts.map +1 -1
- package/build/actions/list.js +10 -12
- package/build/actions/list.js.map +1 -1
- package/build/actions/watch.d.ts +5 -6
- package/build/actions/watch.d.ts.map +1 -1
- package/build/actions/watch.js +21 -20
- package/build/actions/watch.js.map +1 -1
- package/build/entrypoints.d.ts +3 -4
- package/build/entrypoints.d.ts.map +1 -1
- package/build/entrypoints.js +10 -10
- package/build/entrypoints.js.map +1 -1
- package/build/helper.d.ts +3 -5
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +9 -11
- package/build/helper.js.map +1 -1
- package/build/index.d.ts +0 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +0 -3
- package/build/index.js.map +1 -1
- package/build/types.d.ts +18 -46
- package/build/types.d.ts.map +1 -1
- package/build/utils/index.d.ts +2 -2
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js +2 -2
- package/build/utils/index.js.map +1 -1
- package/build/utils/policy.d.ts +7 -0
- package/build/utils/policy.d.ts.map +1 -0
- package/build/utils/policy.js +10 -0
- package/build/utils/policy.js.map +1 -0
- package/build/utils/view.d.ts +18 -0
- package/build/utils/view.d.ts.map +1 -0
- package/build/utils/view.js +94 -0
- package/build/utils/view.js.map +1 -0
- package/package.json +17 -14
- package/src/actions/cancel.ts +15 -12
- package/src/actions/get.ts +10 -10
- package/src/actions/list.ts +15 -21
- package/src/actions/watch.ts +24 -25
- package/src/helper.ts +10 -18
- package/src/index.ts +0 -3
- package/src/types.ts +21 -48
- package/src/utils/index.ts +2 -2
- package/src/utils/policy.ts +20 -0
- package/src/utils/view.ts +102 -0
- package/tests/view.spec.ts +55 -0
- package/src/consts.ts +0 -28
- package/src/entrypoints.ts +0 -56
- package/src/schemas.ts +0 -19
- package/src/utils/owner.ts +0 -60
- package/src/utils/resource.ts +0 -40
package/src/actions/cancel.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
4
|
-
import
|
|
5
|
-
import { jobViewer, jobsOf, readOwnedJob } from '../utils/index.js'
|
|
1
|
+
import { handlers } from '@owlmeans/server-api'
|
|
2
|
+
import { UnknownJob } from '@owlmeans/queue'
|
|
3
|
+
import type { Context, JobEntrypoints, JobHandlerOptions } from '../types.js'
|
|
4
|
+
import { jobsOf, readExposedJob } from '../utils/index.js'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Cancel a job and answer with what was cancelled.
|
|
@@ -15,12 +14,16 @@ import { jobViewer, jobsOf, readOwnedJob } from '../utils/index.js'
|
|
|
15
14
|
* @throws {UnknownJob}
|
|
16
15
|
*/
|
|
17
16
|
export const cancelJob = (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
protocol: JobEntrypoints['cancel'],
|
|
18
|
+
opts: JobHandlerOptions
|
|
19
|
+
): ReturnType<ReturnType<typeof handlers<Context>>['params']> =>
|
|
20
|
+
handlers<Context>().params(protocol, async ({ id }, ctx, req) => {
|
|
21
21
|
const resource = jobsOf(ctx, opts)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
const audience = await opts.policy.audience(req, ctx)
|
|
23
|
+
const record = await readExposedJob(resource, id, audience, opts)
|
|
24
|
+
if (opts.policy.cancel == null || !await opts.policy.cancel(record, audience)) {
|
|
25
|
+
throw new UnknownJob('job')
|
|
26
|
+
}
|
|
27
|
+
const removed = await resource.take(record.id as string)
|
|
28
|
+
return await opts.policy.map(removed, audience)
|
|
26
29
|
})
|
package/src/actions/get.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
import
|
|
4
|
-
import type { JobHandlerOptions } from '../types.js'
|
|
5
|
-
import { jobViewer, jobsOf, readOwnedJob } from '../utils/index.js'
|
|
1
|
+
import { handlers } from '@owlmeans/server-api'
|
|
2
|
+
import type { Context, JobEntrypoints, JobHandlerOptions } from '../types.js'
|
|
3
|
+
import { jobsOf, readExposedJob } from '../utils/index.js'
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
6
|
* One job.
|
|
@@ -10,10 +8,12 @@ import { jobViewer, jobsOf, readOwnedJob } from '../utils/index.js'
|
|
|
10
8
|
* @throws {UnknownJob} for an id that is absent AND for one that belongs to someone else.
|
|
11
9
|
*/
|
|
12
10
|
export const getJob = (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
protocol: JobEntrypoints['get'],
|
|
12
|
+
opts: JobHandlerOptions
|
|
13
|
+
): ReturnType<ReturnType<typeof handlers<Context>>['params']> =>
|
|
14
|
+
handlers<Context>().params(protocol, async ({ id }, ctx, req) => {
|
|
16
15
|
const resource = jobsOf(ctx, opts)
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const audience = await opts.policy.audience(req, ctx)
|
|
17
|
+
const record = await readExposedJob(resource, id, audience, opts)
|
|
18
|
+
return await opts.policy.map(record, audience)
|
|
19
19
|
})
|
package/src/actions/list.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
5
|
-
import
|
|
6
|
-
import type { JobState } from '@owlmeans/queue'
|
|
7
|
-
import { DEFAULT_JOB_SORT } from '../consts.js'
|
|
8
|
-
import type { JobHandlerOptions, JobListQuery } from '../types.js'
|
|
9
|
-
import { jobScope, jobViewer, jobsOf } from '../utils/index.js'
|
|
1
|
+
import { handlers } from '@owlmeans/server-api'
|
|
2
|
+
import type { JobView } from '@owlmeans/job'
|
|
3
|
+
import type { ListResult } from '@owlmeans/resource'
|
|
4
|
+
import type { Context, JobEntrypoints, JobHandlerOptions, JobListQuery } from '../types.js'
|
|
5
|
+
import { jobsOf } from '../utils/index.js'
|
|
10
6
|
|
|
11
7
|
/**
|
|
12
8
|
* The caller's jobs, newest first.
|
|
@@ -17,20 +13,18 @@ import { jobScope, jobViewer, jobsOf } from '../utils/index.js'
|
|
|
17
13
|
* for this list means the same thing applied to the store the browser holds.
|
|
18
14
|
*/
|
|
19
15
|
export const listJobs = (
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
protocol: JobEntrypoints['list'],
|
|
17
|
+
opts: JobHandlerOptions
|
|
18
|
+
): ReturnType<ReturnType<typeof handlers<Context>>['request']> => handlers<Context>().request(protocol, async (req, ctx) => {
|
|
22
19
|
const resource = jobsOf(ctx, opts)
|
|
23
|
-
const
|
|
20
|
+
const audience = await opts.policy.audience(req, ctx)
|
|
24
21
|
const query = (req.query ?? {}) as JobListQuery
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
...jobScope(viewer, opts),
|
|
28
|
-
...(query.state != null ? { state: query.state as JobState } : {}),
|
|
29
|
-
...(query.name != null ? { name: query.name } : {}),
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return await resource.list(where, {
|
|
33
|
-
sort: [{ field: DEFAULT_JOB_SORT, order: 'desc' }],
|
|
22
|
+
const listed = await resource.list(opts.policy.where(audience, query), {
|
|
23
|
+
sort: [{ field: 'createdAt', order: 'desc' }],
|
|
34
24
|
...(query.size != null ? { size: query.size, page: query.page ?? 0 } : {}),
|
|
35
25
|
})
|
|
26
|
+
return {
|
|
27
|
+
...listed,
|
|
28
|
+
items: await Promise.all(listed.items.map(record => opts.policy.map(record, audience))),
|
|
29
|
+
} satisfies ListResult<JobView>
|
|
36
30
|
})
|
package/src/actions/watch.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { AbstractResponse } from '@owlmeans/entrypoint'
|
|
3
|
-
import type { RefedEntrypointHandler } from '@owlmeans/server-entrypoint'
|
|
1
|
+
import { connection } from '@owlmeans/server-socket'
|
|
4
2
|
import type { EventMessage } from '@owlmeans/socket'
|
|
5
3
|
import { MessageType } from '@owlmeans/socket'
|
|
4
|
+
import { JOB_EVENT } from '@owlmeans/job'
|
|
5
|
+
import type { JobViewEvent } from '@owlmeans/job'
|
|
6
6
|
import type { JobEvent } from '@owlmeans/queue'
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import { jobViewer, jobsOf, owns } from '../utils/index.js'
|
|
7
|
+
import type { Context, JobEntrypoints, JobHandlerOptions } from '../types.js'
|
|
8
|
+
import { jobsOf } from '../utils/index.js'
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* Push this caller's job lifecycle events down a socket.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Broker events are resolved inside the caller's audience and mapped to {@link JobViewEvent}; raw
|
|
14
|
+
* queue ids, payloads, failures and ownership fields never cross the socket boundary.
|
|
16
15
|
*
|
|
17
16
|
* **A `JobEvent` carries no owner**, so each one is attributed by reading its job back, and the
|
|
18
17
|
* ids that answered are remembered for the life of the connection. A queue configured with
|
|
@@ -21,30 +20,30 @@ import { jobViewer, jobsOf, owns } from '../utils/index.js'
|
|
|
21
20
|
* rather than fanned out to everyone. Leave completed jobs in place on any queue that is watched.
|
|
22
21
|
*/
|
|
23
22
|
export const watchJobs = (
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
protocol: JobEntrypoints['watch'],
|
|
24
|
+
opts: JobHandlerOptions
|
|
25
|
+
): ReturnType<typeof connection> => connection<typeof protocol, Context>(protocol, async (conn, ctx, req) => {
|
|
26
26
|
const resource = jobsOf(ctx, opts)
|
|
27
|
-
const
|
|
28
|
-
const
|
|
27
|
+
const audience = await opts.policy.audience(req, ctx)
|
|
28
|
+
const publicIds = new Map<string, string>()
|
|
29
29
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const project = async (event: JobEvent): Promise<JobViewEvent | null> => {
|
|
31
|
+
const record = await resource.load({
|
|
32
|
+
$and: [{ id: event.id }, opts.policy.where(audience, {})],
|
|
33
|
+
})
|
|
34
|
+
if (record != null) {
|
|
35
|
+
const job = await opts.policy.map(record, audience)
|
|
36
|
+
publicIds.set(event.id, job.id)
|
|
37
|
+
return { type: 'upsert', job }
|
|
33
38
|
}
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
return false
|
|
37
|
-
}
|
|
38
|
-
mine.add(event.id)
|
|
39
|
-
|
|
40
|
-
return true
|
|
39
|
+
const id = publicIds.get(event.id)
|
|
40
|
+
return id == null ? null : { type: 'remove', id }
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const unsubscribe = await resource.subscribe(async event => {
|
|
44
44
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
45
|
+
const projected = await project(event)
|
|
46
|
+
if (projected != null) await conn.notify(JOB_EVENT, projected)
|
|
48
47
|
} catch (e) {
|
|
49
48
|
console.error('Job watch notify error:', e)
|
|
50
49
|
}
|
package/src/helper.ts
CHANGED
|
@@ -1,28 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ServerEntrypoint } from '@owlmeans/server-entrypoint'
|
|
3
|
-
import type { CommonEntrypoint } from '@owlmeans/entrypoint'
|
|
4
|
-
import { jobEntrypointAliases } from './entrypoints.js'
|
|
1
|
+
import { bind } from '@owlmeans/server-entrypoint'
|
|
5
2
|
import { cancelJob, getJob, listJobs, watchJobs } from './actions/index.js'
|
|
6
|
-
import type { JobHandlerOptions } from './types.js'
|
|
3
|
+
import type { JobEntrypoints, JobHandlerOptions } from './types.js'
|
|
7
4
|
|
|
8
5
|
/**
|
|
9
6
|
* Attach this package's handlers to a group declared by {@link declareJobEntrypoints}.
|
|
10
7
|
*
|
|
11
8
|
* The whole server half of "a long job reports progress to the user's screen" is this call plus
|
|
12
9
|
* the queue driver an app already wires — nothing is subclassed, and an app that wants one
|
|
13
|
-
* handler of its own
|
|
10
|
+
* handler of its own binds that alias itself afterwards.
|
|
14
11
|
*
|
|
15
12
|
* @throws {SyntaxError} when the array carries no group under that root.
|
|
16
13
|
*/
|
|
17
|
-
export const serveJobEntrypoints =
|
|
18
|
-
entrypoints
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
elevate(entrypoints, aliases.watch, watchJobs(opts))
|
|
25
|
-
elevate(entrypoints, aliases.get, getJob(opts))
|
|
26
|
-
|
|
27
|
-
return elevate(entrypoints, aliases.cancel, cancelJob(opts))
|
|
28
|
-
}
|
|
14
|
+
export const serveJobEntrypoints = (entrypoints: JobEntrypoints, opts: JobHandlerOptions) => [
|
|
15
|
+
bind(entrypoints.base),
|
|
16
|
+
bind(entrypoints.list, listJobs(entrypoints.list, opts)),
|
|
17
|
+
bind(entrypoints.watch, watchJobs(entrypoints.watch, opts)),
|
|
18
|
+
bind(entrypoints.get, getJob(entrypoints.get, opts)),
|
|
19
|
+
bind(entrypoints.cancel, cancelJob(entrypoints.cancel, opts)),
|
|
20
|
+
]
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
2
|
import type { AbstractRequest } from '@owlmeans/entrypoint'
|
|
3
|
-
import type {
|
|
3
|
+
import type { JobEntrypoints, JobListQuery, JobView } from '@owlmeans/job'
|
|
4
|
+
import type { JobRecord, QueueAppend, QueueConfig, QueueResource } from '@owlmeans/queue'
|
|
5
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
4
6
|
import type { ApiServerAppend } from '@owlmeans/server-api'
|
|
5
7
|
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
6
8
|
|
|
@@ -11,56 +13,27 @@ export interface Config extends ServerConfig {
|
|
|
11
13
|
export interface Context<C extends Config = Config> extends ServerContext<C>,
|
|
12
14
|
ApiServerAppend, QueueAppend { }
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
export interface JobEntrypointAliases {
|
|
16
|
-
base: string
|
|
17
|
-
list: string
|
|
18
|
-
get: string
|
|
19
|
-
cancel: string
|
|
20
|
-
watch: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface JobEntrypointOptions {
|
|
24
|
-
/** The path segment the group answers under. Defaults to `/jobs`. */
|
|
25
|
-
path?: string
|
|
26
|
-
/** The entrypoint the group hangs under — an app's API base. Top level when omitted. */
|
|
27
|
-
parent?: string
|
|
28
|
-
/** The service route the group answers on, when it is not this app's own. */
|
|
29
|
-
service?: string
|
|
30
|
-
/**
|
|
31
|
-
* The guard the group's base carries, and every entrypoint under it inherits.
|
|
32
|
-
*
|
|
33
|
-
* `DEFAULT_GUARD` unless told otherwise, because ownership is derived from the authenticated
|
|
34
|
-
* subject and an unguarded declaration has no subject to derive it from. Pass `null` only for a
|
|
35
|
-
* group that is scoped some other way — its handlers then answer `AuthorizationError`.
|
|
36
|
-
*/
|
|
37
|
-
guard?: string | null
|
|
38
|
-
}
|
|
16
|
+
export type JobAudience = Record<string, unknown>
|
|
39
17
|
|
|
40
|
-
/**
|
|
41
|
-
export interface
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
18
|
+
/** Required application policy at the raw-broker to public-domain boundary. */
|
|
19
|
+
export interface JobExposurePolicy<A extends JobAudience = JobAudience> {
|
|
20
|
+
audience: (req: AbstractRequest, ctx: BasicContext<BasicConfig>) => A | Promise<A>
|
|
21
|
+
/** Translate public filters and authenticated scope into broker criteria. */
|
|
22
|
+
where: (audience: A, query: JobListQuery) => Criteria<JobRecord>
|
|
23
|
+
/** Resolve an opaque public id within the authenticated scope. */
|
|
24
|
+
lookup: (
|
|
25
|
+
id: string, audience: A, resource: QueueResource
|
|
26
|
+
) => Promise<JobRecord | null>
|
|
27
|
+
/** Allowlist and map a scoped broker record into its public representation. */
|
|
28
|
+
map: (record: JobRecord, audience: A) => JobView | Promise<JobView>
|
|
29
|
+
/** Cancellation is unavailable unless this callback is present and approves the record. */
|
|
30
|
+
cancel?: (record: JobRecord, audience: A) => boolean | Promise<boolean>
|
|
46
31
|
}
|
|
47
32
|
|
|
48
|
-
|
|
49
|
-
* A request that may read the queue unscoped.
|
|
50
|
-
*
|
|
51
|
-
* A predicate rather than a permission name: which permission, gate or role means "operator" is
|
|
52
|
-
* the application's decision, and hardcoding one here would make every deployment that names it
|
|
53
|
-
* differently patch this package.
|
|
54
|
-
*/
|
|
55
|
-
export interface JobAdminCheck {
|
|
56
|
-
(req: AbstractRequest, ctx: BasicContext<BasicConfig>): boolean | Promise<boolean>
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface JobHandlerOptions {
|
|
33
|
+
export interface JobHandlerOptions<A extends JobAudience = JobAudience> {
|
|
60
34
|
/** Which declared queue these handlers read. The context's sole queue when omitted. */
|
|
61
35
|
queue?: string
|
|
62
|
-
|
|
63
|
-
ownerField?: string
|
|
64
|
-
/** The escape hatch — see {@link JobAdminCheck}. Nothing is unscoped without one. */
|
|
65
|
-
admin?: JobAdminCheck
|
|
36
|
+
policy: JobExposurePolicy<A>
|
|
66
37
|
}
|
|
38
|
+
|
|
39
|
+
export type { JobEntrypoints, JobListQuery }
|
package/src/utils/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from './
|
|
2
|
-
export * from './
|
|
1
|
+
export * from './policy.js'
|
|
2
|
+
export * from './view.js'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import type { JobRecord, QueueResource } from '@owlmeans/queue'
|
|
3
|
+
import { UnknownJob } from '@owlmeans/queue'
|
|
4
|
+
import type { Context, JobAudience, JobHandlerOptions } from '../types.js'
|
|
5
|
+
|
|
6
|
+
export const jobsOf = <D = unknown, R = unknown>(
|
|
7
|
+
ctx: BasicContext<BasicConfig>, opts: JobHandlerOptions
|
|
8
|
+
): QueueResource<D, R> => (ctx as unknown as Context).jobs<D, R>(opts.queue)
|
|
9
|
+
|
|
10
|
+
/** Resolve an opaque id without revealing whether it exists outside the current audience. */
|
|
11
|
+
export const readExposedJob = async <A extends JobAudience>(
|
|
12
|
+
resource: QueueResource,
|
|
13
|
+
id: string,
|
|
14
|
+
audience: A,
|
|
15
|
+
opts: JobHandlerOptions<A>,
|
|
16
|
+
): Promise<JobRecord> => {
|
|
17
|
+
const record = await opts.policy.lookup(id, audience, resource)
|
|
18
|
+
if (record == null) throw new UnknownJob('job')
|
|
19
|
+
return record
|
|
20
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { JobViewStatus } from '@owlmeans/job'
|
|
2
|
+
import type { JobJson, JobProgressView, JobView, PublicJobError } from '@owlmeans/job'
|
|
3
|
+
import { JobState } from '@owlmeans/queue'
|
|
4
|
+
import type { JobRecord } from '@owlmeans/queue'
|
|
5
|
+
|
|
6
|
+
export interface JobViewFields {
|
|
7
|
+
id: string
|
|
8
|
+
kind: string
|
|
9
|
+
summary?: string
|
|
10
|
+
metadata?: Record<string, JobJson>
|
|
11
|
+
result?: JobJson
|
|
12
|
+
error?: PublicJobError
|
|
13
|
+
cancellable?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MAX_JSON_DEPTH = 8
|
|
17
|
+
const MAX_JSON_ITEMS = 100
|
|
18
|
+
const MAX_JSON_STRING = 16_384
|
|
19
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
|
|
20
|
+
|
|
21
|
+
/** Copy JSON through bounded, prototype-free containers and discard every non-JSON value. */
|
|
22
|
+
export const sanitizeJobJson = (value: unknown, depth: number = 0): JobJson | undefined => {
|
|
23
|
+
if (depth > MAX_JSON_DEPTH) return undefined
|
|
24
|
+
if (value == null || typeof value === 'boolean' || typeof value === 'string') {
|
|
25
|
+
return typeof value === 'string' ? value.slice(0, MAX_JSON_STRING) : value
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
return value.slice(0, MAX_JSON_ITEMS)
|
|
30
|
+
.map(item => sanitizeJobJson(item, depth + 1))
|
|
31
|
+
.filter((item): item is JobJson => item !== undefined)
|
|
32
|
+
}
|
|
33
|
+
if (typeof value !== 'object') return undefined
|
|
34
|
+
|
|
35
|
+
const result: Record<string, JobJson> = Object.create(null) as Record<string, JobJson>
|
|
36
|
+
for (const [key, item] of Object.entries(value).slice(0, MAX_JSON_ITEMS)) {
|
|
37
|
+
if (FORBIDDEN_KEYS.has(key)) continue
|
|
38
|
+
const safe = sanitizeJobJson(item, depth + 1)
|
|
39
|
+
if (safe !== undefined) result[key] = safe
|
|
40
|
+
}
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sanitizeError = (error: PublicJobError | undefined): PublicJobError | undefined => error == null
|
|
45
|
+
? undefined
|
|
46
|
+
: {
|
|
47
|
+
type: error.type.slice(0, 128),
|
|
48
|
+
message: error.message.slice(0, 2048),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const statusOf = (state?: JobState): JobViewStatus => {
|
|
52
|
+
switch (state) {
|
|
53
|
+
case JobState.Active: return JobViewStatus.Running
|
|
54
|
+
case JobState.Completed: return JobViewStatus.Succeeded
|
|
55
|
+
case JobState.Failed: return JobViewStatus.Failed
|
|
56
|
+
default: return JobViewStatus.Queued
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const progressOf = (value: unknown): JobProgressView | undefined => {
|
|
61
|
+
if (typeof value === 'number' && Number.isFinite(value)) return { percent: value }
|
|
62
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
63
|
+
const raw = value as Record<string, unknown>
|
|
64
|
+
const progress: JobProgressView = {}
|
|
65
|
+
if (typeof raw.percent === 'number' && Number.isFinite(raw.percent)) progress.percent = raw.percent
|
|
66
|
+
if (typeof raw.done === 'number' && Number.isFinite(raw.done)) progress.done = raw.done
|
|
67
|
+
if (typeof raw.total === 'number' && Number.isFinite(raw.total)) progress.total = raw.total
|
|
68
|
+
if (typeof raw.message === 'string') progress.message = raw.message.slice(0, 512)
|
|
69
|
+
return Object.keys(progress).length === 0 ? undefined : progress
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Build a public view from fields an application explicitly allowlisted. */
|
|
73
|
+
export const jobViewOf = (record: JobRecord, fields: JobViewFields): JobView => {
|
|
74
|
+
const createdAt = record.createdAt ?? new Date(0).toISOString()
|
|
75
|
+
const updatedAt = record.finishedAt ?? record.startedAt ?? createdAt
|
|
76
|
+
const progress = progressOf(record.progress)
|
|
77
|
+
const metadata = sanitizeJobJson(fields.metadata)
|
|
78
|
+
const result = sanitizeJobJson(fields.result)
|
|
79
|
+
const error = sanitizeError(fields.error)
|
|
80
|
+
return {
|
|
81
|
+
id: fields.id,
|
|
82
|
+
kind: fields.kind.slice(0, 128),
|
|
83
|
+
status: statusOf(record.state),
|
|
84
|
+
...(progress == null ? {} : { progress }),
|
|
85
|
+
...(fields.summary == null ? {} : { summary: fields.summary.slice(0, 2048) }),
|
|
86
|
+
...(metadata == null || Array.isArray(metadata) || typeof metadata !== 'object'
|
|
87
|
+
? {} : { metadata }),
|
|
88
|
+
...(result === undefined ? {} : { result }),
|
|
89
|
+
...(error == null ? {} : { error }),
|
|
90
|
+
cancellable: fields.cancellable ?? false,
|
|
91
|
+
createdAt,
|
|
92
|
+
updatedAt,
|
|
93
|
+
...(record.startedAt == null ? {} : { startedAt: record.startedAt }),
|
|
94
|
+
...(record.finishedAt == null ? {} : { finishedAt: record.finishedAt }),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Safe default for a broker failure; raw reasons and stacks never cross the boundary. */
|
|
99
|
+
export const publicJobError = (type: string = 'job-failed'): PublicJobError => ({
|
|
100
|
+
type,
|
|
101
|
+
message: 'The background operation failed.',
|
|
102
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { JobViewStatus } from '@owlmeans/job'
|
|
3
|
+
import { JobState } from '@owlmeans/queue'
|
|
4
|
+
import { jobViewOf, publicJobError, sanitizeJobJson } from '../src/utils/view.js'
|
|
5
|
+
|
|
6
|
+
describe('@owlmeans/server-job — public projection boundary', () => {
|
|
7
|
+
test('maps only allowlisted fields and never copies a broker payload or raw failure', () => {
|
|
8
|
+
const view = jobViewOf({
|
|
9
|
+
id: 'broker-id', queue: 'private-lane', name: 'internal:build',
|
|
10
|
+
data: { accessToken: 'secret' }, error: 'stack and connection string',
|
|
11
|
+
state: JobState.Failed, progress: { percent: 65, secret: 'drop' },
|
|
12
|
+
createdAt: '2026-09-18T10:00:00.000Z', finishedAt: '2026-09-18T10:01:00.000Z',
|
|
13
|
+
}, {
|
|
14
|
+
id: 'opaque-id', kind: 'export', summary: 'Export failed',
|
|
15
|
+
error: publicJobError('export-failed'),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
expect(view).toMatchObject({
|
|
19
|
+
id: 'opaque-id', kind: 'export', status: JobViewStatus.Failed,
|
|
20
|
+
progress: { percent: 65 }, error: {
|
|
21
|
+
type: 'export-failed', message: 'The background operation failed.',
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
expect(view).not.toHaveProperty('queue')
|
|
25
|
+
expect(view).not.toHaveProperty('name')
|
|
26
|
+
expect(view).not.toHaveProperty('data')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('copies public JSON into bounded prototype-free values and drops executable data', () => {
|
|
30
|
+
const input = Object.create({ inherited: 'drop' }) as Record<string, unknown>
|
|
31
|
+
input.visible = { ok: true, fn: () => 'drop', infinity: Number.POSITIVE_INFINITY }
|
|
32
|
+
input.list = [1, undefined, Symbol('drop'), 'kept']
|
|
33
|
+
|
|
34
|
+
const sanitized = sanitizeJobJson(input) as Record<string, unknown>
|
|
35
|
+
expect(sanitized).toEqual({ visible: { ok: true }, list: [1, 'kept'] })
|
|
36
|
+
expect(Object.getPrototypeOf(sanitized)).toBeNull()
|
|
37
|
+
expect(sanitized).not.toHaveProperty('inherited')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('caps public text before it reaches a serializer or a browser store', () => {
|
|
41
|
+
const view = jobViewOf({
|
|
42
|
+
id: 'raw', queue: 'lane', name: 'internal', state: JobState.Active,
|
|
43
|
+
progress: { message: 'm'.repeat(900) },
|
|
44
|
+
}, {
|
|
45
|
+
id: 'public', kind: 'k'.repeat(200), summary: 's'.repeat(3_000),
|
|
46
|
+
error: { type: 't'.repeat(200), message: 'e'.repeat(3_000) },
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(view.kind).toHaveLength(128)
|
|
50
|
+
expect(view.summary).toHaveLength(2048)
|
|
51
|
+
expect(view.progress?.message).toHaveLength(512)
|
|
52
|
+
expect(view.error?.type).toHaveLength(128)
|
|
53
|
+
expect(view.error?.message).toHaveLength(2048)
|
|
54
|
+
})
|
|
55
|
+
})
|
package/src/consts.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The alias root a target app gets when its declaration names none, and the path segment the
|
|
3
|
-
* group answers under.
|
|
4
|
-
*/
|
|
5
|
-
export const DEFAULT_JOB_ROOT = 'jobs'
|
|
6
|
-
export const DEFAULT_JOB_PATH = '/jobs'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Where the owner is recorded inside a job's payload.
|
|
10
|
-
*
|
|
11
|
-
* A `JobRecord` has no owner column — the broker keeps only what the contract declares — so the
|
|
12
|
-
* producer writes the subject into the job's own `data`, and every read here filters on
|
|
13
|
-
* `data.<field>`. Change it per declaration when the app's payloads already name the subject
|
|
14
|
-
* something else.
|
|
15
|
-
*/
|
|
16
|
-
export const DEFAULT_OWNER_FIELD = 'owner'
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* The socket event a `JobEvent` frame is pushed under.
|
|
20
|
-
*
|
|
21
|
-
* This name, and the `<root>:<verb>` alias shape in `entrypoints.ts`, are the whole contract
|
|
22
|
-
* between this package and `@owlmeans/client-job` — the two halves cannot share a module without
|
|
23
|
-
* dragging fastify into a browser bundle, so they each state it and the skills pin it.
|
|
24
|
-
*/
|
|
25
|
-
export const JOB_EVENT = 'job-event'
|
|
26
|
-
|
|
27
|
-
/** Newest first: a job list is read to see what is happening now. */
|
|
28
|
-
export const DEFAULT_JOB_SORT = 'createdAt'
|
package/src/entrypoints.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { entrypoint, filter, guard, query } from '@owlmeans/entrypoint'
|
|
2
|
-
import type { CommonEntrypoint } from '@owlmeans/entrypoint'
|
|
3
|
-
import { backend, route, RouteMethod, socket } from '@owlmeans/route'
|
|
4
|
-
import type { RouteOptions } from '@owlmeans/route'
|
|
5
|
-
import { DEFAULT_GUARD } from '@owlmeans/auth-common'
|
|
6
|
-
import { DEFAULT_JOB_PATH } from './consts.js'
|
|
7
|
-
import { JobListQuerySchema } from './schemas.js'
|
|
8
|
-
import type { JobEntrypointAliases, JobEntrypointOptions } from './types.js'
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* The aliases one job group answers under.
|
|
12
|
-
*
|
|
13
|
-
* The shape — `<root>` and `<root>:<verb>` — is the contract `@owlmeans/client-job` addresses the
|
|
14
|
-
* same group by, so a group renamed here is renamed there by passing the same root.
|
|
15
|
-
*/
|
|
16
|
-
export const jobEntrypointAliases = (root: string): JobEntrypointAliases => ({
|
|
17
|
-
base: root,
|
|
18
|
-
list: `${root}:list`,
|
|
19
|
-
get: `${root}:get`,
|
|
20
|
-
cancel: `${root}:cancel`,
|
|
21
|
-
watch: `${root}:watch`,
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Declare the list/get/cancel/watch entrypoints of one job group.
|
|
26
|
-
*
|
|
27
|
-
* It belongs in the SHARED package of a target app — the one both the API and the browser import —
|
|
28
|
-
* so that the server elevates and the client calls the very declarations, and neither side ever
|
|
29
|
-
* writes a path. Declaring a second group is the same call with another root.
|
|
30
|
-
*
|
|
31
|
-
* The guard rides on the base alone: guards are inherited, so stating it once is what keeps the
|
|
32
|
-
* four from drifting apart.
|
|
33
|
-
*/
|
|
34
|
-
export const declareJobEntrypoints = (
|
|
35
|
-
root: string, opts?: JobEntrypointOptions
|
|
36
|
-
): CommonEntrypoint[] => {
|
|
37
|
-
const aliases = jobEntrypointAliases(root)
|
|
38
|
-
const base: Partial<RouteOptions> = {
|
|
39
|
-
...(opts?.parent != null ? { parent: opts.parent } : {}),
|
|
40
|
-
...(opts?.service != null ? { service: opts.service } : {}),
|
|
41
|
-
}
|
|
42
|
-
const guarded = opts?.guard === null ? undefined : guard(opts?.guard ?? DEFAULT_GUARD)
|
|
43
|
-
|
|
44
|
-
return [
|
|
45
|
-
entrypoint(route(aliases.base, opts?.path ?? DEFAULT_JOB_PATH, backend(base)), guarded),
|
|
46
|
-
entrypoint(
|
|
47
|
-
route(aliases.list, '/', backend(aliases.base)),
|
|
48
|
-
filter(query(JobListQuerySchema))
|
|
49
|
-
),
|
|
50
|
-
// Static before parametric, so `/watch` is not swallowed by `/:id`. The router picks the
|
|
51
|
-
// static branch on its own; the order here is for whoever reads the declaration.
|
|
52
|
-
entrypoint(route(aliases.watch, '/watch', socket(aliases.base))),
|
|
53
|
-
entrypoint(route(aliases.get, '/:id', backend(aliases.base))),
|
|
54
|
-
entrypoint(route(aliases.cancel, '/:id', backend(aliases.base, RouteMethod.DELETE))),
|
|
55
|
-
]
|
|
56
|
-
}
|
package/src/schemas.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { JSONSchemaType } from 'ajv'
|
|
2
|
-
import type { JobListQuery } from './types.js'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Every field is optional: a list screen opens before it has a filter, and a caller that knows
|
|
6
|
-
* nothing about jobs must still get the page it is allowed to see. `state` and `name` are left as
|
|
7
|
-
* plain strings rather than enums so that a driver reporting a state this line does not know
|
|
8
|
-
* narrows the list instead of failing the request.
|
|
9
|
-
*/
|
|
10
|
-
export const JobListQuerySchema: JSONSchemaType<JobListQuery> = {
|
|
11
|
-
type: 'object',
|
|
12
|
-
properties: {
|
|
13
|
-
state: { type: 'string', maxLength: 32, nullable: true },
|
|
14
|
-
name: { type: 'string', maxLength: 256, nullable: true },
|
|
15
|
-
page: { type: 'integer', minimum: 0, nullable: true },
|
|
16
|
-
size: { type: 'integer', minimum: 1, maximum: 200, nullable: true },
|
|
17
|
-
},
|
|
18
|
-
additionalProperties: true,
|
|
19
|
-
}
|