@owlmeans/viable-common 0.0.38 → 0.0.40

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 (62) hide show
  1. package/README.md +4 -4
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/agent-presentation/SKILL.md +1 -1
  4. package/agent-meta/skills/viable-common/SKILL.md +40 -19
  5. package/build/areas/index.d.ts +1 -0
  6. package/build/areas/index.d.ts.map +1 -1
  7. package/build/areas/index.js +1 -0
  8. package/build/areas/index.js.map +1 -1
  9. package/build/areas/tenancy.d.ts +55 -0
  10. package/build/areas/tenancy.d.ts.map +1 -0
  11. package/build/areas/tenancy.js +33 -0
  12. package/build/areas/tenancy.js.map +1 -0
  13. package/build/blueprint/types.d.ts +19 -1
  14. package/build/blueprint/types.d.ts.map +1 -1
  15. package/build/blueprint/types.js.map +1 -1
  16. package/build/connect/consts.d.ts +3 -15
  17. package/build/connect/consts.d.ts.map +1 -1
  18. package/build/connect/consts.js +3 -15
  19. package/build/connect/consts.js.map +1 -1
  20. package/build/connect/entrypoints.d.ts +33 -69
  21. package/build/connect/entrypoints.d.ts.map +1 -1
  22. package/build/connect/entrypoints.js +5 -25
  23. package/build/connect/entrypoints.js.map +1 -1
  24. package/build/connect/references.d.ts +1 -31
  25. package/build/connect/references.d.ts.map +1 -1
  26. package/build/connect/references.js +0 -7
  27. package/build/connect/references.js.map +1 -1
  28. package/build/connect/types.d.ts +1 -25
  29. package/build/connect/types.d.ts.map +1 -1
  30. package/build/dev/consts.d.ts +15 -0
  31. package/build/dev/consts.d.ts.map +1 -1
  32. package/build/dev/consts.js +16 -0
  33. package/build/dev/consts.js.map +1 -1
  34. package/build/dev/schemas.d.ts +7 -0
  35. package/build/dev/schemas.d.ts.map +1 -1
  36. package/build/dev/schemas.js +9 -1
  37. package/build/dev/schemas.js.map +1 -1
  38. package/build/dev/types.d.ts +15 -1
  39. package/build/dev/types.d.ts.map +1 -1
  40. package/build/planning/schemas.d.ts.map +1 -1
  41. package/build/planning/schemas.js +25 -0
  42. package/build/planning/schemas.js.map +1 -1
  43. package/build/planning/types.d.ts +9 -0
  44. package/build/planning/types.d.ts.map +1 -1
  45. package/package.json +6 -6
  46. package/src/areas/index.ts +1 -0
  47. package/src/areas/tenancy.ts +69 -0
  48. package/src/blueprint/types.ts +19 -1
  49. package/src/connect/consts.ts +3 -16
  50. package/src/connect/entrypoints.ts +10 -60
  51. package/src/connect/references.ts +4 -26
  52. package/src/connect/types.ts +1 -22
  53. package/src/dev/consts.ts +16 -0
  54. package/src/dev/schemas.ts +13 -1
  55. package/src/dev/types.ts +15 -1
  56. package/src/planning/schemas.ts +25 -0
  57. package/src/planning/types.ts +9 -0
  58. package/tests/access-schema.spec.ts +37 -0
  59. package/tests/connect-convert.spec.ts +5 -11
  60. package/tests/connect-entrypoints.spec.ts +33 -14
  61. package/tests/planning.spec.ts +26 -0
  62. package/tests/tenancy.spec.ts +48 -0
@@ -186,8 +186,7 @@ export interface ConnectProfileSettingsView extends ConnectProfileSettings {
186
186
  * work: a conversion reads somebody else's whole repository, which is the one case where handing
187
187
  * the inference to the parent agent is the cheap default rather than the experimental option.
188
188
  *
189
- * OPTIONAL for the same reason {@link ConnectCapabilitiesView.defaults} carries `converterLlm`
190
- * optionally: the two converter fields are answered by a platform build that does not exist yet,
189
+ * OPTIONAL: the two converter fields are answered by a platform build that does not exist yet,
191
190
  * and this package is consumed by the platform through a workspace link rather than a published
192
191
  * range — a required field here is a compile error in every handler that already returns this
193
192
  * view. Tightened to required once the platform's conversion handlers fill them.
@@ -251,26 +250,6 @@ export interface ConnectProjectBranding {
251
250
  */
252
251
  export interface ConnectProjectBrandingSave extends Partial<ConnectProjectBranding> {}
253
252
 
254
- /** What the platform tells a connector about itself. */
255
- export interface ConnectCapabilitiesView {
256
- /** Tier → the platform role names that will be asked for at that tier. */
257
- tiers: Record<ModelTier, string[]>
258
- /** Whether this caller may open a local-LLM session. */
259
- localLlm: boolean
260
- /**
261
- * The default mode for a new session, resolved from the caller's settings.
262
- *
263
- * `converterLlm` is OPTIONAL: an older platform does not send it, and a client that treated its
264
- * absence as a value would pin every conversion to whatever its own default happened to be.
265
- */
266
- defaults: { target: ConnectTarget, llm: ConnectLlm, converterLlm?: ConnectLlm }
267
- limits: {
268
- pullWaitMs: number
269
- modelTaskTimeoutMs: number
270
- maxPending: number
271
- }
272
- }
273
-
274
253
  /** Attach an existing project to a session. */
275
254
  export interface ConnectAttachBody {
276
255
  projectId?: string
package/src/dev/consts.ts CHANGED
@@ -10,6 +10,22 @@ export enum AccessLevel {
10
10
  Admin = 'admin',
11
11
  }
12
12
 
13
+ /**
14
+ * Who holds a permission without anybody granting it — the default CLASS of its definition.
15
+ *
16
+ * Evaluated by the IAM when it issues claims, never copied onto rows, so a person who joins an
17
+ * organization later holds what its members hold with nothing written for them. `User` is every
18
+ * signed-in person of the application; `Member` and `Owner` are the members and the owners of the
19
+ * organization being acted in, and therefore mean anything only for an organization-bound
20
+ * permission. `None` is the ordinary permission: held only where it was granted.
21
+ */
22
+ export enum PermissionDefault {
23
+ None = 'none',
24
+ User = 'user',
25
+ Member = 'member',
26
+ Owner = 'owner',
27
+ }
28
+
13
29
  /**
14
30
  * Alias prefixes of the entrypoints a generated project declares.
15
31
  *
@@ -80,7 +80,10 @@ export const EntrypointRefListSchema: JSONSchemaType<{entries: EntrypointRef[]}>
80
80
  additionalProperties: false
81
81
  }
82
82
 
83
- export const AccessBlockSchema: JSONSchemaType<AccessBlock> = {
83
+ /** The part of an {@link AccessBlock} a model is asked for. */
84
+ type ModelAccessBlock = Omit<AccessBlock, 'defaults' | 'entityScoped'>
85
+
86
+ const ModelAccessBlockSchema: JSONSchemaType<ModelAccessBlock> = {
84
87
  type: "object",
85
88
  title: "AccessBlock",
86
89
  description: "Object with permissions array and access level",
@@ -119,6 +122,15 @@ export const AccessBlockSchema: JSONSchemaType<AccessBlock> = {
119
122
  additionalProperties: false
120
123
  }
121
124
 
125
+ /**
126
+ * The access block a model writes.
127
+ *
128
+ * It names only what a model is asked for: `defaults` and `entityScoped` are written by code and
129
+ * must never reach a prompt, which `tests/access-schema.spec.ts` pins byte for byte. A value it
130
+ * admits is still an {@link AccessBlock}, because both code-written keys are optional.
131
+ */
132
+ export const AccessBlockSchema = ModelAccessBlockSchema as unknown as JSONSchemaType<AccessBlock>
133
+
122
134
  export const AccessListSchema: JSONSchemaType<AccessList> = {
123
135
  type: "object",
124
136
  title: "AccessList",
package/src/dev/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AccessLevel } from "./consts.js"
1
+ import type { AccessLevel, PermissionDefault } from "./consts.js"
2
2
 
3
3
  export interface SourceFile {
4
4
  path: string
@@ -33,6 +33,20 @@ export interface AccessBlock {
33
33
  level: AccessLevel
34
34
  /** Gate params safe for every newly signed-in user; a subset of permissions. */
35
35
  defaultEnabledPermissions?: string[]
36
+ /**
37
+ * The default class of each permission this block names, keyed by the gate param with its
38
+ * `@<routeParam>` selector stripped — the name the definition is registered under.
39
+ *
40
+ * Written by CODE, never by a model: `AccessBlockSchema` does not carry it, so a model answer
41
+ * never holds one, and what decides it is the project's tenancy and the area, not a reading of
42
+ * the story.
43
+ */
44
+ defaults?: Record<string, PermissionDefault>
45
+ /**
46
+ * Whether the permissions of this block are bound to the organization being acted in, rather than
47
+ * held everywhere. Written by code, like {@link AccessBlock.defaults}.
48
+ */
49
+ entityScoped?: boolean
36
50
  }
37
51
 
38
52
  /**
@@ -1,6 +1,7 @@
1
1
  import type { JSONSchemaType } from 'ajv'
2
2
  import { CODE_MAX } from '@owlmeans/planning'
3
3
  import { ProjectArea } from '../areas/consts.js'
4
+ import { TENANCY_QUOTE_MAX } from '../areas/tenancy.js'
4
5
  import { StoryKind } from '../ba/consts.js'
5
6
  import { ConnectLlm, ConnectTarget } from '../connect/consts.js'
6
7
  import { ProjectOriginSchema } from '../convert/schemas.js'
@@ -40,6 +41,30 @@ export const ViableProjectFieldsSchema = {
40
41
  required: ['at'],
41
42
  additionalProperties: false,
42
43
  },
44
+ // An absent `tenancy` is "never decided"; a recorded one always carries both flags, so a
45
+ // reader never has to guess what a missing flag meant.
46
+ tenancy: {
47
+ type: 'object',
48
+ nullable: true,
49
+ properties: {
50
+ operators: { type: 'boolean' },
51
+ users: { type: 'boolean' },
52
+ quotes: {
53
+ type: 'object',
54
+ nullable: true,
55
+ properties: {
56
+ operators: { type: 'string', minLength: 1, maxLength: TENANCY_QUOTE_MAX, nullable: true },
57
+ users: { type: 'string', minLength: 1, maxLength: TENANCY_QUOTE_MAX, nullable: true },
58
+ },
59
+ required: [],
60
+ additionalProperties: false,
61
+ },
62
+ by: { type: 'string', enum: ['model', 'owner'] },
63
+ at: { type: 'string', minLength: 1, maxLength: 32 },
64
+ },
65
+ required: ['operators', 'users', 'by', 'at'],
66
+ additionalProperties: false,
67
+ },
43
68
  },
44
69
  required: [],
45
70
  additionalProperties: false,
@@ -1,5 +1,6 @@
1
1
  import type { Card, Project, Specification } from '@owlmeans/planning'
2
2
  import type { ProjectArea } from '../areas/consts.js'
3
+ import type { ViableTenancyDecision } from '../areas/tenancy.js'
3
4
  import type { StoryKind } from '../ba/consts.js'
4
5
  import type { ConnectLlm, ConnectTarget } from '../connect/consts.js'
5
6
  import type { ProjectOrigin } from '../convert/types.js'
@@ -42,6 +43,14 @@ export type ViableProjectFields = {
42
43
  * "none" and no later run would ask again. `at` is when it was decided, an ISO timestamp.
43
44
  */
44
45
  landing?: ViableLandingDecision
46
+ /**
47
+ * Whether the target splits its operators and its end users into tenant organizations.
48
+ *
49
+ * ABSENT means never decided and reads as a single-organization application, exactly as every
50
+ * project before tenancy. It reaches a run through the blueprint reference (`BlueprintRef.tenancy`)
51
+ * only when a flag is on.
52
+ */
53
+ tenancy?: ViableTenancyDecision
45
54
  }
46
55
 
47
56
  /** The recorded landing-gate decision — see {@link ViableProjectFields.landing}. */
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createHash } from 'node:crypto'
3
+ import { AccessBlockSchema, AccessLevel, AccessListSchema, PermissionDefault } from '../src/index.js'
4
+ import type { AccessBlock } from '../src/index.js'
5
+
6
+ const sha256 = (value: unknown): string =>
7
+ createHash('sha256').update(JSON.stringify(value)).digest('hex')
8
+
9
+ // The model-facing schemas, byte for byte. They are rendered into the access prompts verbatim and
10
+ // handed to the model as its answer contract, so a change here changes what every model is asked —
11
+ // update these only together with the prompts that read them.
12
+ const ACCESS_BLOCK_SHA256 = '577c8406a73604128ff016a35fe918960d1316a1a24fe4737be31e41478c6ca8'
13
+ const ACCESS_LIST_SHA256 = '80b3ab836718ce69c97e9854f647f2b5ed794ec4b871e86ae94b0fad4bf68a50'
14
+
15
+ describe('viable-common - the access schema a model is asked for', () => {
16
+ test('names only the model-written keys, never the code-written defaults or binding', () => {
17
+ expect(Object.keys(AccessBlockSchema.properties ?? {})).toEqual(
18
+ ['permissions', 'level', 'defaultEnabledPermissions']
19
+ )
20
+ expect(JSON.stringify(AccessListSchema)).not.toContain('entityScoped')
21
+ expect(JSON.stringify(AccessListSchema)).not.toContain('"defaults"')
22
+ })
23
+
24
+ test('is byte-identical to the schema the access prompts were written against', () => {
25
+ expect(sha256(AccessBlockSchema)).toBe(ACCESS_BLOCK_SHA256)
26
+ expect(sha256(AccessListSchema)).toBe(ACCESS_LIST_SHA256)
27
+ })
28
+
29
+ test('an access block still carries the code-written keys as data', () => {
30
+ const block: AccessBlock = {
31
+ permissions: ['enquiry--view@enquiryId'], level: AccessLevel.Permissioned,
32
+ defaults: { 'enquiry--view': PermissionDefault.Member }, entityScoped: true,
33
+ }
34
+ expect(block.defaults).toEqual({ 'enquiry--view': 'member' })
35
+ expect(Object.values(PermissionDefault)).toEqual(['none', 'user', 'member', 'owner'])
36
+ })
37
+ })
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import Ajv from 'ajv'
3
3
  import addFormats from 'ajv-formats'
4
- import { RouteMethod, route } from '@owlmeans/route'
5
- import { aliasOf, openProtocol, protocols } from '@owlmeans/entrypoint'
4
+ import { RouteMethod } from '@owlmeans/route'
5
+ import { aliasOf, protocols } from '@owlmeans/entrypoint'
6
6
  import { connect, ConnectInquiryKind, ConnectOpKind } from '../src/connect/consts.js'
7
7
  import { connectProtocols } from '../src/connect/entrypoints.js'
8
8
  import { connectRef } from '../src/connect/references.js'
@@ -20,7 +20,6 @@ const entrypoints = () => connectProtocols({
20
20
  guard: 'test-guard',
21
21
  gate: { alias: 'test-gate', params: ['id'] },
22
22
  localLlm: { alias: 'test-paid-llm', params: ['id'] },
23
- updateBase: openProtocol(route('test:update:base', '/update')),
24
23
  })
25
24
 
26
25
  const entrypointOf = (alias: string) => {
@@ -102,11 +101,9 @@ describe('viable-common - the conversion additions to the connector contract', (
102
101
  [connect.convert.check, '/convert/:id/check', RouteMethod.GET],
103
102
  [connect.convert.start, '/convert/:id/start', RouteMethod.POST],
104
103
  [connect.convert.proceed, '/convert/:id/proceed', RouteMethod.POST],
105
- [connect.convert.cancel, '/convert/:id/cancel', RouteMethod.POST],
106
104
  [connect.convert.status, '/convert/:id', RouteMethod.GET],
107
105
  [connect.convert.purge, '/convert/:id/purge', RouteMethod.POST],
108
106
  [connect.inquiry.answer, '/project/:id/inquiry/:inquiryId', RouteMethod.POST],
109
- [connect.project.converterLlm, '/project/:id/converter-llm', RouteMethod.POST],
110
107
  [connect.files.list, '/project/:id/files', RouteMethod.GET],
111
108
  ]
112
109
 
@@ -122,21 +119,18 @@ describe('viable-common - the conversion additions to the connector contract', (
122
119
  expect(connectRef.convert.check.alias).toBe(connect.convert.check)
123
120
  expect(connectRef.convert.start.alias).toBe(connect.convert.start)
124
121
  expect(connectRef.convert.proceed.alias).toBe(connect.convert.proceed)
125
- expect(connectRef.convert.cancel.alias).toBe(connect.convert.cancel)
126
122
  expect(connectRef.convert.status.alias).toBe(connect.convert.status)
127
123
  expect(connectRef.convert.purge.alias).toBe(connect.convert.purge)
128
124
  expect(connectRef.inquiry.answer.alias).toBe(connect.inquiry.answer)
129
- expect(connectRef.project.converterLlm.alias).toBe(connect.project.converterLlm)
130
125
  expect(connectRef.files.list.alias).toBe(connect.files.list)
131
126
  })
132
127
 
133
128
  test('the conversion routes hang under the connector base and carry no paid gate', () => {
134
129
  // Delegated inference is the DEFAULT for a conversion, not an experimental capability — so
135
- // unlike `project.llm`, nothing here sits behind the local-LLM gate.
136
- expect(entrypointOf(connect.project.llm).gate?.alias).toBe('test-paid-llm')
130
+ // unlike the delegated session, nothing here sits behind the local-LLM gate.
131
+ expect(entrypointOf(connect.session.openDelegated).gate?.alias).toBe('test-paid-llm')
137
132
 
138
- const gated = [...Object.values(connect.convert), connect.inquiry.answer,
139
- connect.project.converterLlm]
133
+ const gated = [...Object.values(connect.convert), connect.inquiry.answer]
140
134
  for (const alias of gated) {
141
135
  expect(entrypointOf(alias).gate, alias).toBeUndefined()
142
136
  expect(aliasOf(routeOf(alias).parent!), alias).toBe(connect.base)
@@ -1,30 +1,48 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import Ajv from 'ajv'
3
- import { aliasOf, openProtocol, protocols } from '@owlmeans/entrypoint'
3
+ import { aliasOf, protocols } from '@owlmeans/entrypoint'
4
4
  import { DEFAULT_GUARD } from '@owlmeans/auth-common'
5
- import { route, RouteMethod } from '@owlmeans/route'
5
+ import { RouteMethod, RouteProtocols } from '@owlmeans/route'
6
6
  import { connect, CONNECT_BRANDING_GOOGLE_TAG_MAX } from '../src/connect/consts.js'
7
7
  import { ConnectProjectBrandingSaveSchema } from '../src/connect/schemas.js'
8
8
  import { connectProtocols } from '../src/connect/entrypoints.js'
9
9
  import { connectRef } from '../src/connect/references.js'
10
10
 
11
11
  describe('@owlmeans/viable-common — connector protocol tree', () => {
12
- const tree = connectProtocols({
13
- guard: DEFAULT_GUARD,
14
- updateBase: openProtocol(route('manager:update', '/update')),
15
- })
12
+ const tree = connectProtocols({ guard: DEFAULT_GUARD })
16
13
 
17
14
  test('exports named immutable declarations and a complete flat materialization view', () => {
18
15
  expect(tree.base.alias).toBe(connect.base)
19
16
  expect(tree.session.open.alias).toBe(connect.session.open)
20
- expect(tree.project.llm.alias).toBe(connect.project.llm)
21
- expect(tree.project.converterLlm.alias).toBe(connect.project.converterLlm)
22
17
  expect(tree.story.status.alias).toBe(connect.story.status)
23
18
  expect(tree.convert.proceed.alias).toBe(connect.convert.proceed)
24
19
  expect(tree.inquiry.answer.alias).toBe(connect.inquiry.answer)
25
20
  expect(tree.files.list.alias).toBe(connect.files.list)
26
- expect(protocols(tree)).toHaveLength(34)
27
- expect(new Set(protocols(tree).map(protocol => protocol.alias)).size).toBe(34)
21
+ expect(protocols(tree)).toHaveLength(26)
22
+ expect(new Set(protocols(tree).map(protocol => protocol.alias)).size).toBe(26)
23
+ })
24
+
25
+ test('declares exactly the routes a connector calls — every alias, reference and path, and no socket', () => {
26
+ const declared = protocols(tree)
27
+ const addresses = declared.filter(protocol => protocol.route.route.method != null)
28
+ .map(protocol => `${protocol.route.route.method!.toUpperCase()} ${protocol.route.route.path}`).sort()
29
+
30
+ expect(addresses).toEqual([
31
+ 'GET /convert/:id', 'GET /convert/:id/check', 'GET /pipeline/:id/:runId', 'GET /project',
32
+ 'GET /project/:id/branding', 'GET /project/:id/files', 'GET /project/:id/status',
33
+ 'GET /project/:id/story/:storyId/status', 'GET /session/:sessionId/ops',
34
+ 'POST /convert', 'POST /convert/:id/proceed', 'POST /convert/:id/purge', 'POST /convert/:id/start',
35
+ 'POST /pipeline/:id/:runId/resume', 'POST /project', 'POST /project/:id/branding',
36
+ 'POST /project/:id/confirm', 'POST /project/:id/inquiry/:inquiryId', 'POST /project/:id/modify',
37
+ 'POST /project/:id/reinit', 'POST /project/attach', 'POST /session', 'POST /session/:sessionId/close',
38
+ 'POST /session/:sessionId/ops/:opId', 'POST /session/delegated',
39
+ ])
40
+ expect(declared.every(protocol => protocol.route.route.protocol !== RouteProtocols.SOCKET)).toBe(true)
41
+ // The alias table, the tree and the typed references name the same routes.
42
+ const leaves = (value: object): string[] => Object.values(value).flatMap(entry =>
43
+ typeof entry === 'string' ? [entry] : 'alias' in entry ? [entry.alias as string] : leaves(entry))
44
+ expect(leaves(connect).sort()).toEqual(declared.map(protocol => protocol.alias).sort())
45
+ expect(leaves(connectRef).sort()).toEqual(declared.map(protocol => protocol.alias).filter(alias => alias !== connect.base).sort())
28
46
  })
29
47
 
30
48
  test('reads and saves a project\'s branding at one path, under the owned base and no paid gate', () => {
@@ -32,7 +50,6 @@ describe('@owlmeans/viable-common — connector protocol tree', () => {
32
50
  guard: DEFAULT_GUARD,
33
51
  gate: { alias: 'test-gate', params: ['owner'] },
34
52
  localLlm: { alias: 'test-paid-llm', params: ['cap'] },
35
- updateBase: openProtocol(route('manager:update', '/update')),
36
53
  })
37
54
  const get = paid.project.branding.get
38
55
  const save = paid.project.branding.save
@@ -45,9 +62,11 @@ describe('@owlmeans/viable-common — connector protocol tree', () => {
45
62
  expect(save.route.route.method).toBe(RouteMethod.POST)
46
63
  expect(aliasOf(get.route.route.parent)).toBe(connect.base)
47
64
  expect(aliasOf(save.route.route.parent)).toBe(connect.base)
48
- // The paid local-LLM gate sits on its own two routes; branding carries no gate of its own and
49
- // inherits only the base's guard and ownership gate.
50
- expect(paid.project.llm.gate?.alias).toBe('test-paid-llm')
65
+ // The paid local-LLM gate sits on the delegated session alone; branding carries no gate of its
66
+ // own and inherits only the base's guard and ownership gate.
67
+ expect(paid.session.openDelegated.gate?.alias).toBe('test-paid-llm')
68
+ expect(protocols(paid).filter(protocol => protocol.gate?.alias === 'test-paid-llm'))
69
+ .toEqual([paid.session.openDelegated])
51
70
  expect(get.gate).toBeUndefined()
52
71
  expect(save.gate).toBeUndefined()
53
72
  expect(connectRef.project.branding.save.alias).toBe(connect.project.branding.save)
@@ -276,6 +276,32 @@ describe('viable-common - the viable field schemas', () => {
276
276
  expect(project({ landing: { story: 'US-ABC12' } })).toBe(false)
277
277
  expect(project({ landing: { story: 'US-ABC12', at, reason: 'best fit' } })).toBe(false)
278
278
  })
279
+
280
+ test('a project records a tenancy decision with both flags, who decided it and when', () => {
281
+ expect(project({ tenancy: { operators: false, users: true, by: 'model', at } })).toBe(true)
282
+ expect(project({
283
+ tenancy: {
284
+ operators: true, users: true, by: 'owner', at,
285
+ quotes: { operators: 'Each agency manages its own staff.', users: null },
286
+ },
287
+ })).toBe(true)
288
+ expect(project({ tenancy: null })).toBe(true)
289
+ })
290
+
291
+ test('a project refuses a partial tenancy decision, an unknown decider and a stray key', () => {
292
+ expect(project({ tenancy: { users: true, by: 'model', at } })).toBe(false)
293
+ expect(project({ tenancy: { operators: true, users: false, at } })).toBe(false)
294
+ expect(project({ tenancy: { operators: true, users: false, by: 'model' } })).toBe(false)
295
+ expect(project({ tenancy: { operators: 'yes', users: false, by: 'model', at } })).toBe(false)
296
+ expect(project({ tenancy: { operators: true, users: false, by: 'agent', at } })).toBe(false)
297
+ expect(project({ tenancy: { operators: true, users: false, by: 'model', at, reason: 'b2b' } })).toBe(false)
298
+ expect(project({
299
+ tenancy: { operators: true, users: false, by: 'model', at, quotes: { staff: 'x' } },
300
+ })).toBe(false)
301
+ expect(project({
302
+ tenancy: { operators: true, users: false, by: 'model', at, quotes: { operators: 'x'.repeat(1025) } },
303
+ })).toBe(false)
304
+ })
279
305
  })
280
306
 
281
307
  describe('viable-common - the viable card helpers', () => {
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { NO_TENANCY, ProjectArea, tenancyOf, tenantedArea } from '../src/index.js'
3
+ import { LandingGatePreference } from '../src/blueprint/index.js'
4
+
5
+ const experience = (tenancy: unknown) =>
6
+ ({ experience: { landingGate: LandingGatePreference.Allow, tenancy: tenancy as never } })
7
+
8
+ describe('viable-common - the project tenancy', () => {
9
+ test('NO_TENANCY is both flags off and cannot be changed', () => {
10
+ expect(NO_TENANCY).toEqual({ operators: false, users: false })
11
+ expect(Object.isFrozen(NO_TENANCY)).toBe(true)
12
+ })
13
+
14
+ test('tenancyOf answers NO_TENANCY for no blueprint, no layer, no key and an unreadable value', () => {
15
+ expect(tenancyOf()).toBe(NO_TENANCY)
16
+ expect(tenancyOf(null)).toBe(NO_TENANCY)
17
+ expect(tenancyOf({})).toBe(NO_TENANCY)
18
+ expect(tenancyOf({ experience: { landingGate: LandingGatePreference.Allow } })).toBe(NO_TENANCY)
19
+ expect(tenancyOf(experience(null))).toBe(NO_TENANCY)
20
+ expect(tenancyOf(experience('both'))).toBe(NO_TENANCY)
21
+ expect(tenancyOf(experience({ operators: 'yes', users: 1 }))).toBe(NO_TENANCY)
22
+ expect(tenancyOf(experience({ operators: false, users: false }))).toBe(NO_TENANCY)
23
+ expect(tenancyOf({ experience: 'flat' as never })).toBe(NO_TENANCY)
24
+ })
25
+
26
+ test('tenancyOf reads a literal true per flag, and an absent flag as off', () => {
27
+ expect(tenancyOf(experience({ operators: true, users: false }))).toEqual({ operators: true, users: false })
28
+ expect(tenancyOf(experience({ users: true }))).toEqual({ operators: false, users: true })
29
+ const both = tenancyOf(experience({ operators: true, users: true }))
30
+ expect(both).toEqual({ operators: true, users: true })
31
+ expect(Object.isFrozen(both)).toBe(true)
32
+ })
33
+
34
+ test('tenantedArea: user follows users, operator follows operators, guest and admin never', () => {
35
+ const all = { operators: true, users: true }
36
+ expect(tenantedArea(ProjectArea.User, all)).toBe(true)
37
+ expect(tenantedArea(ProjectArea.Operator, all)).toBe(true)
38
+ expect(tenantedArea(ProjectArea.Guest, all)).toBe(false)
39
+ expect(tenantedArea(ProjectArea.Admin, all)).toBe(false)
40
+
41
+ expect(tenantedArea(ProjectArea.User, { operators: true, users: false })).toBe(false)
42
+ expect(tenantedArea(ProjectArea.Operator, { operators: false, users: true })).toBe(false)
43
+ for (const area of Object.values(ProjectArea)) {
44
+ expect(tenantedArea(area, NO_TENANCY)).toBe(false)
45
+ }
46
+ expect(tenantedArea('partner' as ProjectArea, all)).toBe(false)
47
+ })
48
+ })