@owlmeans/payment 0.1.18-rc.7 → 0.1.18-rc.9
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/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/payment/SKILL.md +1 -1
- package/build/entitlement.d.ts +60 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +95 -0
- package/build/entitlement.js.map +1 -0
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/package.json +5 -5
- package/src/entitlement.ts +122 -0
- package/src/index.ts +1 -0
- package/tests/entitlement.spec.d.ts +2 -0
- package/tests/entitlement.spec.d.ts.map +1 -0
- package/tests/entitlement.spec.js +72 -0
- package/tests/entitlement.spec.js.map +1 -0
- package/tests/entitlement.spec.ts +90 -0
- package/tests/tsconfig.json +11 -0
- package/tsconfig.json +6 -1
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/payment",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-08-
|
|
4
|
+
"version": "0.1.18-rc.8",
|
|
5
|
+
"generatedAt": "2026-08-27T16:32:18.507Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { CommonEntrypointOptions } from '@owlmeans/entrypoint';
|
|
2
|
+
import type { PermissionSet } from '@owlmeans/auth';
|
|
3
|
+
/**
|
|
4
|
+
* The gate alias a paid capability is asserted under.
|
|
5
|
+
*
|
|
6
|
+
* Distinct from `paymentGate.base` (a ROUTE id) and from the gateway service alias — three
|
|
7
|
+
* different things that would otherwise all be called "payment gate".
|
|
8
|
+
*/
|
|
9
|
+
export declare const ENTITLEMENT_GATE = "entitlement-gate";
|
|
10
|
+
/**
|
|
11
|
+
* The capability scope that carries FEATURE flags.
|
|
12
|
+
*
|
|
13
|
+
* Kept apart from `renewable`, which carries numeric quotas that are consumed or counted. Merging
|
|
14
|
+
* them would make "has one production slot left" and "may remove the platform credit" the same
|
|
15
|
+
* number, and the first purchase that spent the quota would take the feature with it.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CAPABILITY_FEATURE_SCOPE = "feature";
|
|
18
|
+
export interface EntitlementParam {
|
|
19
|
+
/** Capability scope. Absent means any scope. */
|
|
20
|
+
scope?: string;
|
|
21
|
+
/** The permission key inside `PermissionSet.permissions`. */
|
|
22
|
+
permission: string;
|
|
23
|
+
/** Numeric floor: `credits>=100` passes when the held value is a number at least that big. */
|
|
24
|
+
atLeast?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Grammar: `[<scope>:]<permission>[>=<n>]`.
|
|
28
|
+
*
|
|
29
|
+
* `feature:branding--whitelabel` · `renewable:credits>=100` · `production--standalone`
|
|
30
|
+
*
|
|
31
|
+
* `@` is deliberately not part of it: that is `@owlmeans/iam`'s resource-selector syntax, and a
|
|
32
|
+
* grammar that reused it would make two different things look the same in a route declaration.
|
|
33
|
+
*/
|
|
34
|
+
export declare const parseEntitlementParam: (param: string) => EntitlementParam;
|
|
35
|
+
export declare const formatEntitlementParam: (parsed: EntitlementParam) => string;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a union of capability sets satisfies one requirement.
|
|
38
|
+
*
|
|
39
|
+
* Pure, and shared by the server gate and the browser: the UI has to know whether to render a
|
|
40
|
+
* control disabled, and a second implementation of the rule for the client would eventually
|
|
41
|
+
* disagree with the one that actually refuses.
|
|
42
|
+
*
|
|
43
|
+
* A malformed parameter answers `false` rather than throwing. A gate that crashed on a typo would
|
|
44
|
+
* take down the endpoint it guards, which is a strictly worse failure than refusing it.
|
|
45
|
+
*/
|
|
46
|
+
export declare const hasEntitlement: (capabilities: PermissionSet[] | undefined, param: string) => boolean;
|
|
47
|
+
/** Everything a union grants, formatted back into parameters. The wire shape of a client read. */
|
|
48
|
+
export declare const entitlementList: (capabilities: PermissionSet[] | undefined) => string[];
|
|
49
|
+
/**
|
|
50
|
+
* Declare that an entrypoint needs a paid capability.
|
|
51
|
+
*
|
|
52
|
+
* Sugar over `gate(ENTITLEMENT_GATE, params)` so a route reads as what it means. Several
|
|
53
|
+
* parameters are OR'd, matching every other gate in the framework.
|
|
54
|
+
*
|
|
55
|
+
* Putting the requirement HERE rather than in a handler is the point: the framework asserts a gate
|
|
56
|
+
* before the handler is entered, so the route table states what a feature costs and no new
|
|
57
|
+
* endpoint can forget to check.
|
|
58
|
+
*/
|
|
59
|
+
export declare const entitled: (params: string | string[], opts?: CommonEntrypointOptions) => CommonEntrypointOptions;
|
|
60
|
+
//# sourceMappingURL=entitlement.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entitlement.d.ts","sourceRoot":"","sources":["../src/entitlement.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAA;AACnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,qBAAqB,CAAA;AAElD;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,YAAY,CAAA;AAEjD,MAAM,WAAW,gBAAgB;IAC/B,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,6DAA6D;IAC7D,UAAU,EAAE,MAAM,CAAA;IAClB,8FAA8F;IAC9F,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,UAAW,MAAM,KAAG,gBAerD,CAAA;AAED,eAAO,MAAM,sBAAsB,WAAY,gBAAgB,KAAG,MAER,CAAA;AAE1D;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,iBACX,aAAa,EAAE,GAAG,SAAS,SAAS,MAAM,KACvD,OAqBF,CAAA;AAED,kGAAkG;AAClG,eAAO,MAAM,eAAe,iBAAkB,aAAa,EAAE,GAAG,SAAS,KAAG,MAAM,EAYjF,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,WACX,MAAM,GAAG,MAAM,EAAE,SAAS,uBAAuB,KACxD,uBAA+D,CAAA"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { gate } from '@owlmeans/entrypoint';
|
|
2
|
+
/**
|
|
3
|
+
* The gate alias a paid capability is asserted under.
|
|
4
|
+
*
|
|
5
|
+
* Distinct from `paymentGate.base` (a ROUTE id) and from the gateway service alias — three
|
|
6
|
+
* different things that would otherwise all be called "payment gate".
|
|
7
|
+
*/
|
|
8
|
+
export const ENTITLEMENT_GATE = 'entitlement-gate';
|
|
9
|
+
/**
|
|
10
|
+
* The capability scope that carries FEATURE flags.
|
|
11
|
+
*
|
|
12
|
+
* Kept apart from `renewable`, which carries numeric quotas that are consumed or counted. Merging
|
|
13
|
+
* them would make "has one production slot left" and "may remove the platform credit" the same
|
|
14
|
+
* number, and the first purchase that spent the quota would take the feature with it.
|
|
15
|
+
*/
|
|
16
|
+
export const CAPABILITY_FEATURE_SCOPE = 'feature';
|
|
17
|
+
/**
|
|
18
|
+
* Grammar: `[<scope>:]<permission>[>=<n>]`.
|
|
19
|
+
*
|
|
20
|
+
* `feature:branding--whitelabel` · `renewable:credits>=100` · `production--standalone`
|
|
21
|
+
*
|
|
22
|
+
* `@` is deliberately not part of it: that is `@owlmeans/iam`'s resource-selector syntax, and a
|
|
23
|
+
* grammar that reused it would make two different things look the same in a route declaration.
|
|
24
|
+
*/
|
|
25
|
+
export const parseEntitlementParam = (param) => {
|
|
26
|
+
const [scoped, floor] = param.split('>=');
|
|
27
|
+
const colon = scoped.indexOf(':');
|
|
28
|
+
const parsed = colon >= 0
|
|
29
|
+
? { scope: scoped.slice(0, colon), permission: scoped.slice(colon + 1) }
|
|
30
|
+
: { permission: scoped };
|
|
31
|
+
if (floor != null) {
|
|
32
|
+
const value = Number.parseFloat(floor);
|
|
33
|
+
if (!Number.isNaN(value)) {
|
|
34
|
+
parsed.atLeast = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
};
|
|
39
|
+
export const formatEntitlementParam = (parsed) => `${parsed.scope != null ? `${parsed.scope}:` : ''}${parsed.permission}` +
|
|
40
|
+
`${parsed.atLeast != null ? `>=${parsed.atLeast}` : ''}`;
|
|
41
|
+
/**
|
|
42
|
+
* Whether a union of capability sets satisfies one requirement.
|
|
43
|
+
*
|
|
44
|
+
* Pure, and shared by the server gate and the browser: the UI has to know whether to render a
|
|
45
|
+
* control disabled, and a second implementation of the rule for the client would eventually
|
|
46
|
+
* disagree with the one that actually refuses.
|
|
47
|
+
*
|
|
48
|
+
* A malformed parameter answers `false` rather than throwing. A gate that crashed on a typo would
|
|
49
|
+
* take down the endpoint it guards, which is a strictly worse failure than refusing it.
|
|
50
|
+
*/
|
|
51
|
+
export const hasEntitlement = (capabilities, param) => {
|
|
52
|
+
if (capabilities == null || capabilities.length < 1) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const { scope, permission, atLeast } = parseEntitlementParam(param);
|
|
56
|
+
if (permission === '') {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return capabilities.some(set => {
|
|
60
|
+
if (scope != null && set.scope !== scope) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const held = set.permissions?.[permission];
|
|
64
|
+
if (held == null || held === false) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
// A floor is a numeric question: a boolean flag, however true, does not answer it.
|
|
68
|
+
return atLeast != null ? typeof held === 'number' && held >= atLeast : true;
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
/** Everything a union grants, formatted back into parameters. The wire shape of a client read. */
|
|
72
|
+
export const entitlementList = (capabilities) => {
|
|
73
|
+
const list = [];
|
|
74
|
+
for (const set of capabilities ?? []) {
|
|
75
|
+
for (const [permission, value] of Object.entries(set.permissions ?? {})) {
|
|
76
|
+
if (value == null || value === false) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
list.push(formatEntitlementParam({ scope: set.scope, permission }));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return [...new Set(list)];
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Declare that an entrypoint needs a paid capability.
|
|
86
|
+
*
|
|
87
|
+
* Sugar over `gate(ENTITLEMENT_GATE, params)` so a route reads as what it means. Several
|
|
88
|
+
* parameters are OR'd, matching every other gate in the framework.
|
|
89
|
+
*
|
|
90
|
+
* Putting the requirement HERE rather than in a handler is the point: the framework asserts a gate
|
|
91
|
+
* before the handler is entered, so the route table states what a feature costs and no new
|
|
92
|
+
* endpoint can forget to check.
|
|
93
|
+
*/
|
|
94
|
+
export const entitled = (params, opts) => gate(ENTITLEMENT_GATE, params, opts);
|
|
95
|
+
//# sourceMappingURL=entitlement.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entitlement.js","sourceRoot":"","sources":["../src/entitlement.ts"],"names":[],"mappings":"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"}
|
package/build/index.d.ts
CHANGED
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,kBAAkB,CAAA;AAChC,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,kBAAkB,CAAA;AAChC,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
package/build/index.js
CHANGED
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,kBAAkB,CAAA;AAChC,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,kBAAkB,CAAA;AAChC,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/payment",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.9",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
"ajv": "*"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@owlmeans/auth": "^0.1.18-rc.
|
|
35
|
-
"@owlmeans/basic-envelope": "^0.1.18-rc.
|
|
36
|
-
"@owlmeans/config": "^0.1.18-rc.
|
|
34
|
+
"@owlmeans/auth": "^0.1.18-rc.7",
|
|
35
|
+
"@owlmeans/basic-envelope": "^0.1.18-rc.9",
|
|
36
|
+
"@owlmeans/config": "^0.1.18-rc.8",
|
|
37
37
|
"@owlmeans/context": "^0.1.18-rc.6",
|
|
38
38
|
"@owlmeans/error": "^0.1.18-rc.6",
|
|
39
39
|
"@owlmeans/i18n": "^0.1.18-rc.6",
|
|
40
|
-
"@owlmeans/entrypoint": "^0.1.18-rc.
|
|
40
|
+
"@owlmeans/entrypoint": "^0.1.18-rc.8",
|
|
41
41
|
"@owlmeans/resource": "^0.1.18-rc.7",
|
|
42
42
|
"@owlmeans/route": "^0.1.18-rc.6"
|
|
43
43
|
},
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { gate } from '@owlmeans/entrypoint'
|
|
2
|
+
import type { CommonEntrypointOptions } from '@owlmeans/entrypoint'
|
|
3
|
+
import type { PermissionSet } from '@owlmeans/auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The gate alias a paid capability is asserted under.
|
|
7
|
+
*
|
|
8
|
+
* Distinct from `paymentGate.base` (a ROUTE id) and from the gateway service alias — three
|
|
9
|
+
* different things that would otherwise all be called "payment gate".
|
|
10
|
+
*/
|
|
11
|
+
export const ENTITLEMENT_GATE = 'entitlement-gate'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The capability scope that carries FEATURE flags.
|
|
15
|
+
*
|
|
16
|
+
* Kept apart from `renewable`, which carries numeric quotas that are consumed or counted. Merging
|
|
17
|
+
* them would make "has one production slot left" and "may remove the platform credit" the same
|
|
18
|
+
* number, and the first purchase that spent the quota would take the feature with it.
|
|
19
|
+
*/
|
|
20
|
+
export const CAPABILITY_FEATURE_SCOPE = 'feature'
|
|
21
|
+
|
|
22
|
+
export interface EntitlementParam {
|
|
23
|
+
/** Capability scope. Absent means any scope. */
|
|
24
|
+
scope?: string
|
|
25
|
+
/** The permission key inside `PermissionSet.permissions`. */
|
|
26
|
+
permission: string
|
|
27
|
+
/** Numeric floor: `credits>=100` passes when the held value is a number at least that big. */
|
|
28
|
+
atLeast?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Grammar: `[<scope>:]<permission>[>=<n>]`.
|
|
33
|
+
*
|
|
34
|
+
* `feature:branding--whitelabel` · `renewable:credits>=100` · `production--standalone`
|
|
35
|
+
*
|
|
36
|
+
* `@` is deliberately not part of it: that is `@owlmeans/iam`'s resource-selector syntax, and a
|
|
37
|
+
* grammar that reused it would make two different things look the same in a route declaration.
|
|
38
|
+
*/
|
|
39
|
+
export const parseEntitlementParam = (param: string): EntitlementParam => {
|
|
40
|
+
const [scoped, floor] = param.split('>=')
|
|
41
|
+
const colon = scoped.indexOf(':')
|
|
42
|
+
const parsed: EntitlementParam = colon >= 0
|
|
43
|
+
? { scope: scoped.slice(0, colon), permission: scoped.slice(colon + 1) }
|
|
44
|
+
: { permission: scoped }
|
|
45
|
+
|
|
46
|
+
if (floor != null) {
|
|
47
|
+
const value = Number.parseFloat(floor)
|
|
48
|
+
if (!Number.isNaN(value)) {
|
|
49
|
+
parsed.atLeast = value
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return parsed
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const formatEntitlementParam = (parsed: EntitlementParam): string =>
|
|
57
|
+
`${parsed.scope != null ? `${parsed.scope}:` : ''}${parsed.permission}` +
|
|
58
|
+
`${parsed.atLeast != null ? `>=${parsed.atLeast}` : ''}`
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a union of capability sets satisfies one requirement.
|
|
62
|
+
*
|
|
63
|
+
* Pure, and shared by the server gate and the browser: the UI has to know whether to render a
|
|
64
|
+
* control disabled, and a second implementation of the rule for the client would eventually
|
|
65
|
+
* disagree with the one that actually refuses.
|
|
66
|
+
*
|
|
67
|
+
* A malformed parameter answers `false` rather than throwing. A gate that crashed on a typo would
|
|
68
|
+
* take down the endpoint it guards, which is a strictly worse failure than refusing it.
|
|
69
|
+
*/
|
|
70
|
+
export const hasEntitlement = (
|
|
71
|
+
capabilities: PermissionSet[] | undefined, param: string
|
|
72
|
+
): boolean => {
|
|
73
|
+
if (capabilities == null || capabilities.length < 1) {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
const { scope, permission, atLeast } = parseEntitlementParam(param)
|
|
77
|
+
if (permission === '') {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return capabilities.some(set => {
|
|
82
|
+
if (scope != null && set.scope !== scope) {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
const held = set.permissions?.[permission]
|
|
86
|
+
if (held == null || held === false) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A floor is a numeric question: a boolean flag, however true, does not answer it.
|
|
91
|
+
return atLeast != null ? typeof held === 'number' && held >= atLeast : true
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Everything a union grants, formatted back into parameters. The wire shape of a client read. */
|
|
96
|
+
export const entitlementList = (capabilities: PermissionSet[] | undefined): string[] => {
|
|
97
|
+
const list: string[] = []
|
|
98
|
+
for (const set of capabilities ?? []) {
|
|
99
|
+
for (const [permission, value] of Object.entries(set.permissions ?? {})) {
|
|
100
|
+
if (value == null || value === false) {
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
list.push(formatEntitlementParam({ scope: set.scope, permission }))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return [...new Set(list)]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Declare that an entrypoint needs a paid capability.
|
|
112
|
+
*
|
|
113
|
+
* Sugar over `gate(ENTITLEMENT_GATE, params)` so a route reads as what it means. Several
|
|
114
|
+
* parameters are OR'd, matching every other gate in the framework.
|
|
115
|
+
*
|
|
116
|
+
* Putting the requirement HERE rather than in a handler is the point: the framework asserts a gate
|
|
117
|
+
* before the handler is entered, so the route table states what a feature costs and no new
|
|
118
|
+
* endpoint can forget to check.
|
|
119
|
+
*/
|
|
120
|
+
export const entitled = (
|
|
121
|
+
params: string | string[], opts?: CommonEntrypointOptions
|
|
122
|
+
): CommonEntrypointOptions => gate(ENTITLEMENT_GATE, params, opts)
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entitlement.spec.d.ts","sourceRoot":"","sources":["entitlement.spec.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { CAPABILITY_FEATURE_SCOPE, entitlementList, formatEntitlementParam, hasEntitlement, parseEntitlementParam, } from '../src/entitlement.js';
|
|
3
|
+
const pro = [
|
|
4
|
+
{ scope: 'renewable', permissions: { production: 1, credits: 5000 } },
|
|
5
|
+
{ scope: CAPABILITY_FEATURE_SCOPE, permissions: { 'branding--whitelabel': true, 'domain--custom': true } },
|
|
6
|
+
];
|
|
7
|
+
describe('the parameter grammar', () => {
|
|
8
|
+
test('a bare permission has no scope and no floor', () => {
|
|
9
|
+
expect(parseEntitlementParam('domain--custom')).toEqual({ permission: 'domain--custom' });
|
|
10
|
+
});
|
|
11
|
+
test('a scope is everything before the first colon', () => {
|
|
12
|
+
expect(parseEntitlementParam('feature:branding--whitelabel'))
|
|
13
|
+
.toEqual({ scope: 'feature', permission: 'branding--whitelabel' });
|
|
14
|
+
});
|
|
15
|
+
test('a floor is parsed as a number', () => {
|
|
16
|
+
expect(parseEntitlementParam('renewable:credits>=100'))
|
|
17
|
+
.toEqual({ scope: 'renewable', permission: 'credits', atLeast: 100 });
|
|
18
|
+
});
|
|
19
|
+
test('formatting round-trips', () => {
|
|
20
|
+
for (const param of ['domain--custom', 'feature:branding--whitelabel', 'renewable:credits>=100']) {
|
|
21
|
+
expect(formatEntitlementParam(parseEntitlementParam(param))).toBe(param);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('the predicate', () => {
|
|
26
|
+
test('a held feature passes', () => {
|
|
27
|
+
expect(hasEntitlement(pro, 'feature:branding--whitelabel')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
test('a feature nobody granted does not', () => {
|
|
30
|
+
expect(hasEntitlement(pro, 'feature:branding--nonexistent')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
test('a scope that does not match is not consulted', () => {
|
|
33
|
+
expect(hasEntitlement(pro, 'renewable:branding--whitelabel')).toBe(false);
|
|
34
|
+
// …and without a scope, any set may answer.
|
|
35
|
+
expect(hasEntitlement(pro, 'branding--whitelabel')).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
test('a numeric floor compares', () => {
|
|
38
|
+
expect(hasEntitlement(pro, 'renewable:credits>=5000')).toBe(true);
|
|
39
|
+
expect(hasEntitlement(pro, 'renewable:credits>=5001')).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
test('a boolean cannot satisfy a numeric floor', () => {
|
|
42
|
+
expect(hasEntitlement(pro, 'feature:branding--whitelabel>=1')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
test('an explicit false is not a grant', () => {
|
|
45
|
+
expect(hasEntitlement([{ scope: 'feature', permissions: { x: false } }], 'feature:x')).toBe(false);
|
|
46
|
+
expect(hasEntitlement([{ scope: 'feature', permissions: { x: null } }], 'feature:x')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
test('no capabilities at all is a refusal, never a pass', () => {
|
|
49
|
+
expect(hasEntitlement(undefined, 'feature:branding--whitelabel')).toBe(false);
|
|
50
|
+
expect(hasEntitlement([], 'feature:branding--whitelabel')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
test('a malformed parameter refuses rather than throwing', () => {
|
|
53
|
+
// A gate that crashed on a typo would take down the endpoint it guards.
|
|
54
|
+
expect(() => hasEntitlement(pro, '')).not.toThrow();
|
|
55
|
+
expect(hasEntitlement(pro, '')).toBe(false);
|
|
56
|
+
expect(hasEntitlement(pro, 'feature:')).toBe(false);
|
|
57
|
+
expect(hasEntitlement(pro, 'renewable:credits>=abc')).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('listing', () => {
|
|
61
|
+
test('unions every granted capability, scoped', () => {
|
|
62
|
+
expect(entitlementList(pro).sort()).toEqual([
|
|
63
|
+
'feature:branding--whitelabel', 'feature:domain--custom',
|
|
64
|
+
'renewable:credits', 'renewable:production',
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
test('skips what is not granted', () => {
|
|
68
|
+
expect(entitlementList([{ scope: 'feature', permissions: { a: true, b: false, c: null } }]))
|
|
69
|
+
.toEqual(['feature:a']);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
//# sourceMappingURL=entitlement.spec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entitlement.spec.js","sourceRoot":"","sources":["entitlement.spec.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
CAPABILITY_FEATURE_SCOPE, entitlementList, formatEntitlementParam, hasEntitlement,
|
|
4
|
+
parseEntitlementParam,
|
|
5
|
+
} from '../src/entitlement.js'
|
|
6
|
+
import type { PermissionSet } from '@owlmeans/auth'
|
|
7
|
+
|
|
8
|
+
const pro: PermissionSet[] = [
|
|
9
|
+
{ scope: 'renewable', permissions: { production: 1, credits: 5000 } },
|
|
10
|
+
{ scope: CAPABILITY_FEATURE_SCOPE, permissions: { 'branding--whitelabel': true, 'domain--custom': true } },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
describe('the parameter grammar', () => {
|
|
14
|
+
test('a bare permission has no scope and no floor', () => {
|
|
15
|
+
expect(parseEntitlementParam('domain--custom')).toEqual({ permission: 'domain--custom' })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('a scope is everything before the first colon', () => {
|
|
19
|
+
expect(parseEntitlementParam('feature:branding--whitelabel'))
|
|
20
|
+
.toEqual({ scope: 'feature', permission: 'branding--whitelabel' })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('a floor is parsed as a number', () => {
|
|
24
|
+
expect(parseEntitlementParam('renewable:credits>=100'))
|
|
25
|
+
.toEqual({ scope: 'renewable', permission: 'credits', atLeast: 100 })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('formatting round-trips', () => {
|
|
29
|
+
for (const param of ['domain--custom', 'feature:branding--whitelabel', 'renewable:credits>=100']) {
|
|
30
|
+
expect(formatEntitlementParam(parseEntitlementParam(param))).toBe(param)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('the predicate', () => {
|
|
36
|
+
test('a held feature passes', () => {
|
|
37
|
+
expect(hasEntitlement(pro, 'feature:branding--whitelabel')).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('a feature nobody granted does not', () => {
|
|
41
|
+
expect(hasEntitlement(pro, 'feature:branding--nonexistent')).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a scope that does not match is not consulted', () => {
|
|
45
|
+
expect(hasEntitlement(pro, 'renewable:branding--whitelabel')).toBe(false)
|
|
46
|
+
// …and without a scope, any set may answer.
|
|
47
|
+
expect(hasEntitlement(pro, 'branding--whitelabel')).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a numeric floor compares', () => {
|
|
51
|
+
expect(hasEntitlement(pro, 'renewable:credits>=5000')).toBe(true)
|
|
52
|
+
expect(hasEntitlement(pro, 'renewable:credits>=5001')).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('a boolean cannot satisfy a numeric floor', () => {
|
|
56
|
+
expect(hasEntitlement(pro, 'feature:branding--whitelabel>=1')).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('an explicit false is not a grant', () => {
|
|
60
|
+
expect(hasEntitlement([{ scope: 'feature', permissions: { x: false } }], 'feature:x')).toBe(false)
|
|
61
|
+
expect(hasEntitlement([{ scope: 'feature', permissions: { x: null } }], 'feature:x')).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('no capabilities at all is a refusal, never a pass', () => {
|
|
65
|
+
expect(hasEntitlement(undefined, 'feature:branding--whitelabel')).toBe(false)
|
|
66
|
+
expect(hasEntitlement([], 'feature:branding--whitelabel')).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('a malformed parameter refuses rather than throwing', () => {
|
|
70
|
+
// A gate that crashed on a typo would take down the endpoint it guards.
|
|
71
|
+
expect(() => hasEntitlement(pro, '')).not.toThrow()
|
|
72
|
+
expect(hasEntitlement(pro, '')).toBe(false)
|
|
73
|
+
expect(hasEntitlement(pro, 'feature:')).toBe(false)
|
|
74
|
+
expect(hasEntitlement(pro, 'renewable:credits>=abc')).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('listing', () => {
|
|
79
|
+
test('unions every granted capability, scoped', () => {
|
|
80
|
+
expect(entitlementList(pro).sort()).toEqual([
|
|
81
|
+
'feature:branding--whitelabel', 'feature:domain--custom',
|
|
82
|
+
'renewable:credits', 'renewable:production',
|
|
83
|
+
])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('skips what is not granted', () => {
|
|
87
|
+
expect(entitlementList([{ scope: 'feature', permissions: { a: true, b: false, c: null } }]))
|
|
88
|
+
.toEqual(['feature:a'])
|
|
89
|
+
})
|
|
90
|
+
})
|