@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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/agent-meta/manifest.json +16 -0
  4. package/agent-meta/skills/server-job/SKILL.md +140 -0
  5. package/build/actions/cancel.d.ts +15 -0
  6. package/build/actions/cancel.d.ts.map +1 -0
  7. package/build/actions/cancel.js +19 -0
  8. package/build/actions/cancel.js.map +1 -0
  9. package/build/actions/get.d.ts +10 -0
  10. package/build/actions/get.d.ts.map +1 -0
  11. package/build/actions/get.js +12 -0
  12. package/build/actions/get.js.map +1 -0
  13. package/build/actions/index.d.ts +5 -0
  14. package/build/actions/index.d.ts.map +1 -0
  15. package/build/actions/index.js +5 -0
  16. package/build/actions/index.js.map +1 -0
  17. package/build/actions/list.d.ts +13 -0
  18. package/build/actions/list.d.ts.map +1 -0
  19. package/build/actions/list.js +26 -0
  20. package/build/actions/list.js.map +1 -0
  21. package/build/actions/watch.d.ts +17 -0
  22. package/build/actions/watch.d.ts.map +1 -0
  23. package/build/actions/watch.js +57 -0
  24. package/build/actions/watch.js.map +1 -0
  25. package/build/consts.d.ts +26 -0
  26. package/build/consts.d.ts.map +1 -0
  27. package/build/consts.js +26 -0
  28. package/build/consts.js.map +1 -0
  29. package/build/entrypoints.d.ts +21 -0
  30. package/build/entrypoints.d.ts.map +1 -0
  31. package/build/entrypoints.js +46 -0
  32. package/build/entrypoints.js.map +1 -0
  33. package/build/helper.d.ts +14 -0
  34. package/build/helper.d.ts.map +1 -0
  35. package/build/helper.js +21 -0
  36. package/build/helper.js.map +1 -0
  37. package/build/index.d.ts +8 -0
  38. package/build/index.d.ts.map +1 -0
  39. package/build/index.js +7 -0
  40. package/build/index.js.map +1 -0
  41. package/build/schemas.d.ts +10 -0
  42. package/build/schemas.d.ts.map +1 -0
  43. package/build/schemas.js +17 -0
  44. package/build/schemas.js.map +1 -0
  45. package/build/types.d.ts +60 -0
  46. package/build/types.d.ts.map +1 -0
  47. package/build/types.js +2 -0
  48. package/build/types.js.map +1 -0
  49. package/build/utils/index.d.ts +3 -0
  50. package/build/utils/index.d.ts.map +1 -0
  51. package/build/utils/index.js +3 -0
  52. package/build/utils/index.js.map +1 -0
  53. package/build/utils/owner.d.ts +33 -0
  54. package/build/utils/owner.d.ts.map +1 -0
  55. package/build/utils/owner.js +41 -0
  56. package/build/utils/owner.js.map +1 -0
  57. package/build/utils/resource.d.ts +23 -0
  58. package/build/utils/resource.d.ts.map +1 -0
  59. package/build/utils/resource.js +28 -0
  60. package/build/utils/resource.js.map +1 -0
  61. package/package.json +46 -0
  62. package/src/actions/cancel.ts +26 -0
  63. package/src/actions/get.ts +19 -0
  64. package/src/actions/index.ts +4 -0
  65. package/src/actions/list.ts +36 -0
  66. package/src/actions/watch.ts +66 -0
  67. package/src/consts.ts +28 -0
  68. package/src/entrypoints.ts +56 -0
  69. package/src/helper.ts +28 -0
  70. package/src/index.ts +8 -0
  71. package/src/schemas.ts +19 -0
  72. package/src/types.ts +66 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/owner.ts +60 -0
  75. package/src/utils/resource.ts +40 -0
  76. 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
@@ -0,0 +1,8 @@
1
+ export type * from './types.js'
2
+
3
+ export * from './consts.js'
4
+ export * from './schemas.js'
5
+ export * from './entrypoints.js'
6
+ export * from './helper.js'
7
+ export * from './actions/index.js'
8
+ export * from './utils/index.js'
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,2 @@
1
+ export * from './owner.js'
2
+ export * from './resource.js'
@@ -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
+ }