@owlmeans/server-job 0.1.18-rc.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/LICENSE +21 -0
- package/README.md +114 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/server-job/SKILL.md +140 -0
- package/build/actions/cancel.d.ts +15 -0
- package/build/actions/cancel.d.ts.map +1 -0
- package/build/actions/cancel.js +19 -0
- package/build/actions/cancel.js.map +1 -0
- package/build/actions/get.d.ts +10 -0
- package/build/actions/get.d.ts.map +1 -0
- package/build/actions/get.js +12 -0
- package/build/actions/get.js.map +1 -0
- package/build/actions/index.d.ts +5 -0
- package/build/actions/index.d.ts.map +1 -0
- package/build/actions/index.js +5 -0
- package/build/actions/index.js.map +1 -0
- package/build/actions/list.d.ts +13 -0
- package/build/actions/list.d.ts.map +1 -0
- package/build/actions/list.js +26 -0
- package/build/actions/list.js.map +1 -0
- package/build/actions/watch.d.ts +17 -0
- package/build/actions/watch.d.ts.map +1 -0
- package/build/actions/watch.js +57 -0
- package/build/actions/watch.js.map +1 -0
- package/build/consts.d.ts +26 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +26 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +21 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +46 -0
- package/build/entrypoints.js.map +1 -0
- package/build/helper.d.ts +14 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +21 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/build/schemas.d.ts +10 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +17 -0
- package/build/schemas.js.map +1 -0
- package/build/types.d.ts +60 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/index.d.ts +3 -0
- package/build/utils/index.d.ts.map +1 -0
- package/build/utils/index.js +3 -0
- package/build/utils/index.js.map +1 -0
- package/build/utils/owner.d.ts +33 -0
- package/build/utils/owner.d.ts.map +1 -0
- package/build/utils/owner.js +41 -0
- package/build/utils/owner.js.map +1 -0
- package/build/utils/resource.d.ts +23 -0
- package/build/utils/resource.d.ts.map +1 -0
- package/build/utils/resource.js +28 -0
- package/build/utils/resource.js.map +1 -0
- package/package.json +46 -0
- package/src/actions/cancel.ts +26 -0
- package/src/actions/get.ts +19 -0
- package/src/actions/index.ts +4 -0
- package/src/actions/list.ts +36 -0
- package/src/actions/watch.ts +66 -0
- package/src/consts.ts +28 -0
- package/src/entrypoints.ts +56 -0
- package/src/helper.ts +28 -0
- package/src/index.ts +8 -0
- package/src/schemas.ts +19 -0
- package/src/types.ts +66 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/owner.ts +60 -0
- package/src/utils/resource.ts +40 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,56 @@
|
|
|
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/helper.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { elevate } from '@owlmeans/server-entrypoint'
|
|
2
|
+
import type { ServerEntrypoint } from '@owlmeans/server-entrypoint'
|
|
3
|
+
import type { CommonEntrypoint } from '@owlmeans/entrypoint'
|
|
4
|
+
import { jobEntrypointAliases } from './entrypoints.js'
|
|
5
|
+
import { cancelJob, getJob, listJobs, watchJobs } from './actions/index.js'
|
|
6
|
+
import type { JobHandlerOptions } from './types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Attach this package's handlers to a group declared by {@link declareJobEntrypoints}.
|
|
10
|
+
*
|
|
11
|
+
* The whole server half of "a long job reports progress to the user's screen" is this call plus
|
|
12
|
+
* the queue driver an app already wires — nothing is subclassed, and an app that wants one
|
|
13
|
+
* handler of its own elevates that alias itself afterwards, since `elevate` replaces in place.
|
|
14
|
+
*
|
|
15
|
+
* @throws {SyntaxError} when the array carries no group under that root.
|
|
16
|
+
*/
|
|
17
|
+
export const serveJobEntrypoints = <R>(
|
|
18
|
+
entrypoints: (CommonEntrypoint | ServerEntrypoint<R>)[], root: string, opts?: JobHandlerOptions
|
|
19
|
+
): ServerEntrypoint<R>[] => {
|
|
20
|
+
const aliases = jobEntrypointAliases(root)
|
|
21
|
+
|
|
22
|
+
elevate(entrypoints, aliases.base)
|
|
23
|
+
elevate(entrypoints, aliases.list, listJobs(opts))
|
|
24
|
+
elevate(entrypoints, aliases.watch, watchJobs(opts))
|
|
25
|
+
elevate(entrypoints, aliases.get, getJob(opts))
|
|
26
|
+
|
|
27
|
+
return elevate(entrypoints, aliases.cancel, cancelJob(opts))
|
|
28
|
+
}
|
package/src/index.ts
ADDED
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import type { AbstractRequest } from '@owlmeans/entrypoint'
|
|
3
|
+
import type { QueueAppend, QueueConfig } from '@owlmeans/queue'
|
|
4
|
+
import type { ApiServerAppend } from '@owlmeans/server-api'
|
|
5
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
6
|
+
|
|
7
|
+
export interface Config extends ServerConfig {
|
|
8
|
+
queue?: QueueConfig
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Context<C extends Config = Config> extends ServerContext<C>,
|
|
12
|
+
ApiServerAppend, QueueAppend { }
|
|
13
|
+
|
|
14
|
+
/** The alias every entrypoint of one job group answers under, derived from the group's root. */
|
|
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
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The list entrypoint's query, as it travels on the wire. */
|
|
41
|
+
export interface JobListQuery {
|
|
42
|
+
state?: string
|
|
43
|
+
name?: string
|
|
44
|
+
page?: number
|
|
45
|
+
size?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
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 {
|
|
60
|
+
/** Which declared queue these handlers read. The context's sole queue when omitted. */
|
|
61
|
+
queue?: string
|
|
62
|
+
/** The field inside `JobRecord.data` that names the owner. Defaults to `owner`. */
|
|
63
|
+
ownerField?: string
|
|
64
|
+
/** The escape hatch — see {@link JobAdminCheck}. Nothing is unscoped without one. */
|
|
65
|
+
admin?: JobAdminCheck
|
|
66
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import type { AbstractRequest } from '@owlmeans/entrypoint'
|
|
3
|
+
import { AuthorizationError } from '@owlmeans/auth'
|
|
4
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
5
|
+
import type { JobRecord } from '@owlmeans/queue'
|
|
6
|
+
import { DEFAULT_OWNER_FIELD } from '../consts.js'
|
|
7
|
+
import type { JobHandlerOptions } from '../types.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The subject a job is attributed to.
|
|
11
|
+
*
|
|
12
|
+
* Profile first, user second — the same pair `@owlmeans/server-socket` addresses a connection by,
|
|
13
|
+
* so a job enqueued for one profile of a multi-profile account is not visible to the others.
|
|
14
|
+
*/
|
|
15
|
+
export const jobOwnerOf = (req: AbstractRequest): string | undefined =>
|
|
16
|
+
req.auth?.profileId ?? req.auth?.userId
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @throws {AuthorizationError} when the request carries no authenticated subject.
|
|
20
|
+
*/
|
|
21
|
+
export const requireJobOwner = (req: AbstractRequest): string => {
|
|
22
|
+
const owner = jobOwnerOf(req)
|
|
23
|
+
if (owner == null || owner === '') {
|
|
24
|
+
throw new AuthorizationError()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return owner
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const ownerFieldOf = (opts?: JobHandlerOptions): string =>
|
|
31
|
+
opts?.ownerField ?? DEFAULT_OWNER_FIELD
|
|
32
|
+
|
|
33
|
+
/** What one record says its owner is — the payload's own field, never a broker one. */
|
|
34
|
+
export const ownerOf = (record: JobRecord, opts?: JobHandlerOptions): unknown =>
|
|
35
|
+
(record.data as Record<string, unknown> | undefined)?.[ownerFieldOf(opts)]
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Who this request reads the queue as: the owner every record must name, or `undefined` for a
|
|
39
|
+
* request that reads it unscoped.
|
|
40
|
+
*
|
|
41
|
+
* `undefined` is reachable only through {@link JobHandlerOptions.admin}. Absent an admin check
|
|
42
|
+
* every read is narrowed to the caller's own jobs and an anonymous request is refused, so a group
|
|
43
|
+
* wired with no options at all is scoped rather than open.
|
|
44
|
+
*
|
|
45
|
+
* @throws {AuthorizationError}
|
|
46
|
+
*/
|
|
47
|
+
export const jobViewer = async <C extends BasicConfig, T extends BasicContext<C>>(
|
|
48
|
+
req: AbstractRequest, ctx: T, opts?: JobHandlerOptions
|
|
49
|
+
): Promise<string | undefined> => {
|
|
50
|
+
if (opts?.admin != null && await opts.admin(req, ctx as BasicContext<BasicConfig>)) {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return requireJobOwner(req)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The criteria one viewer reads the queue through; nothing to add when they read it unscoped. */
|
|
58
|
+
export const jobScope = (
|
|
59
|
+
viewer: string | undefined, opts?: JobHandlerOptions
|
|
60
|
+
): Criteria<JobRecord> => viewer == null ? {} : { [`data.${ownerFieldOf(opts)}`]: viewer }
|
|
@@ -0,0 +1,40 @@
|
|
|
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, JobHandlerOptions } from '../types.js'
|
|
5
|
+
import { ownerOf } from './owner.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The queue these handlers read.
|
|
9
|
+
*
|
|
10
|
+
* Named by the declaration or, with nothing named, the context's sole declared queue — which
|
|
11
|
+
* `ctx.jobs()` refuses to guess once a second queue exists, so an app that grows one is told to
|
|
12
|
+
* name it rather than quietly served the wrong backlog.
|
|
13
|
+
*/
|
|
14
|
+
export const jobsOf = <D = unknown, R = unknown>(
|
|
15
|
+
ctx: BasicContext<BasicConfig>, opts?: JobHandlerOptions
|
|
16
|
+
): QueueResource<D, R> => (ctx as unknown as Context).jobs<D, R>(opts?.queue)
|
|
17
|
+
|
|
18
|
+
/** Does this viewer own that record? An unscoped viewer (`undefined`) owns everything. */
|
|
19
|
+
export const owns = (
|
|
20
|
+
record: JobRecord, viewer: string | undefined, opts?: JobHandlerOptions
|
|
21
|
+
): boolean => viewer == null || ownerOf(record, opts) === viewer
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One job, as this viewer is allowed to see it.
|
|
25
|
+
*
|
|
26
|
+
* A job that exists but belongs to someone else answers exactly as an absent one does: telling an
|
|
27
|
+
* unauthorized caller apart from a wrong id is what turns an id space into an enumeration oracle.
|
|
28
|
+
*
|
|
29
|
+
* @throws {UnknownJob}
|
|
30
|
+
*/
|
|
31
|
+
export const readOwnedJob = async <D = unknown, R = unknown>(
|
|
32
|
+
resource: QueueResource<D, R>, id: string, viewer: string | undefined, opts?: JobHandlerOptions
|
|
33
|
+
): Promise<JobRecord<D, R>> => {
|
|
34
|
+
const record = await resource.load(id)
|
|
35
|
+
if (record == null || !owns(record, viewer, opts)) {
|
|
36
|
+
throw new UnknownJob(`${resource.queue}:${id}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return record
|
|
40
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/"
|
|
9
|
+
},
|
|
10
|
+
"exclude": [
|
|
11
|
+
"./dist/**/*",
|
|
12
|
+
"./build/**/*",
|
|
13
|
+
"./tests/**/*",
|
|
14
|
+
"./*.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|